diff --git a/CLAUDE.md b/CLAUDE.md index 6056d4e..d52eb9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,12 @@ dotnet test they may be talking to you through it. - **Verification is tests, `--smoke` and targeted live checks.** No UI automation: no screenshots, synthetic keystrokes or WebView2 remote debugging. Antivirus behavior shields flag those and quarantine the build. -- **Live checks against real accounts:** scratch sessions only, and never a prompt that spends the user's plan usage. +- **Live checks against real accounts:** scratch sessions only, and never a prompt that spends the user's plan usage. A check + that needs the real account (rare) deletes its own sessions afterwards. +- **A scratch `claude` never gets the user's config folder.** Start it with `CLAUDE_CONFIG_DIR` = a fresh temp dir, + `ANTHROPIC_BASE_URL=http://127.0.0.1:9`, a dummy `ANTHROPIC_API_KEY` (answer Yes at "use this API key?" and at "Trust this folder?") and + `ClaudeLaunch.ParentSessionVars` stripped; under Git Bash set `MSYS_NO_PATHCONV=1` (else "/help" becomes a Windows path); delete the temp dir and its cwd when the check ends. Otherwise its sessions + pile up in the session list and `.claude.json`. - **New non-trivial logic leaves a test.** `ThemeLoadTests` builds every view with `App.xaml` loaded, so a missing theme key fails the tests instead of the app. @@ -24,8 +29,8 @@ dotnet test | Folder | What | |---|---| | `src/CCWrapper` (root) | `Program.cs`: the Velopack hook runs before WPF starts | -| `src/CCWrapper/Core` | Accounts and paths, `AppState`, the `LiveSession` contract the views bind to; `AppUpdate` (auto-update), `AppPrefs` (`updateCheck`, `browserTool`) and `CompactPrefs` (`app.json`: each rewrites only its own key) | -| `src/CCWrapper/Data` | Read-only parsers for Claude Code's files (registry, transcripts, file history, `.claude.json`) and for what `claude plugin`/`mcp` print; `SettingsData`, the Settings window's load-edit-save of `settings.json` and `CLAUDE.md`; `ClaudeJson`, locked updates of `.claude.json` | +| `src/CCWrapper/Core` | Accounts and paths, `AppState`, the `LiveSession` contract the views bind to; `Housekeeping` (start-up deletes by age: pasted images, orphaned status line files); `DraftStore` (compose-box drafts, `drafts-.json`, saved on exit, before an update restart and when the app is deactivated with an update it knows of; another window's update restart can still lose the drafts of a window that has not seen the update); `AppUpdate` (auto-update), `AppPrefs` (`updateCheck`, `browserTool`) and `CompactPrefs` (`app.json`: each rewrites only its own key) | +| `src/CCWrapper/Data` | Read-only parsers for Claude Code's files (registry, transcripts, file history, `.claude.json`) and for what `claude plugin`/`mcp` print; `SlashCommands` (the `/` list, with enabled plugins' commands and skills), `UsageStats` (the usage popup's token counts); `SettingsData`, the Settings window's load-edit-save of `settings.json` and `CLAUDE.md`; `ClaudeJson`, locked updates of `.claude.json` | | `src/CCWrapper/Terminal` | ConPTY (`PtySession`), WebView2 + xterm.js (`TerminalHost`, `wwwroot/`), launch, paste, `UsageProbe`, `ProcessTree`, `ClaudeCli` (hidden `claude plugin`/`mcp` runs), `BrowserMcp`/`BrowserPipe` (the Browser tool: MCP handler and the window's named pipe) | | `src/CCWrapper/Views` | WPF windows and controls; pure logic lives in static helpers (`ShellModel`, `BarFormat`, `TuiInput`) | | `src/CCWrapper/Theme.xaml` | Every color and custom style; `App.xaml` recolors Fluent's own brushes | @@ -74,7 +79,8 @@ dotnet test `cachedUsageUtilization` are internal and change with Claude Code releases. Parse them only in `Data/`, treat every field as optional, and show "—" when one is missing. - **Never type into a permission prompt.** Anything that sends text or keys to the TUI goes through `TuiInput`. It - re-reads the registry right before sending and fails closed. + re-reads the registry right before sending and fails closed. A slash command goes in as pastes + of at most 500 characters and one line break each: Claude Code folds a paste over 800 characters or of 4 lines or more into `[Pasted text]` instead of running it. - **WPF cannot draw over WebView2.** Never overlay WPF on the terminal. Collapse it and show a WPF panel in its cell, or put the UI in the terminal page (`wwwroot`). - **Child `claude` processes must not inherit a parent Claude Code session.** `ClaudeLaunch` strips its environment diff --git a/PRIVACY.md b/PRIVACY.md index aeafdae..03af8a8 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -74,17 +74,18 @@ If the runtime is missing on your PC, the installer (`Setup.exe`) downloads it f | Where | What | |---|---| | `%LOCALAPPDATA%\PieMH.CCWrapper` | The installed program. | -| `%LOCALAPPDATA%\CCWrapper\paste` | Images you pasted into the terminal. | -| `%LOCALAPPDATA%\CCWrapper\WebView2` | Browser profiles of the terminal and of the Browser panel, one per account: cookies, cache, site data. | +| `%LOCALAPPDATA%\CCWrapper\paste` | Images you pasted into the terminal (`paste-*.png`). Deleted when they are 7 days old, checked once when the app starts. | +| `%LOCALAPPDATA%\CCWrapper\WebView2` | Browser profiles of the terminal and of the Browser panel, one per account: cookies, cache, site data. Cookies and site data stay until you clear them (or delete the folder). A Browser panel cache over 100 MB is cleared when the panel starts. A leftover `smoke` folder of the developer self-test is deleted when the self-test next runs. | | `%LOCALAPPDATA%\CCWrapper\backups` | Earlier versions of files you saved from the app. | -| `%LOCALAPPDATA%\CCWrapper\live` | Live status data (model, context, cost, usage) from the status-line relay, one small file per tab. | +| `%LOCALAPPDATA%\CCWrapper\live` | Live status data (model, context, cost, usage) from the status-line relay, one small file per tab. Deleted when the tab closes; one a crash left behind is deleted at the next start once it was not written for a day. | +| `%LOCALAPPDATA%\CCWrapper\drafts-.json` | The unsent text of each session's compose box, one file per account: session id, text and cursor position, at most 100 drafts. Written only when the app exits, when it restarts to update, and whenever the app loses focus while it knows an update is ready to install. A restart to update started from another window can still end a window that has not noticed the update yet, and its unsaved drafts are lost. Read when a session is reopened. Not encrypted. A draft is dropped after 14 days without a change, and when you delete its session. If two windows of one account are open, the one that exits last wins. | | `%LOCALAPPDATA%\CCWrapper\app.json` | The app's own settings: `updateCheck`, `browserTool` (whether Claude may use the Browser panel) and the bottom bar's `compact` hint (on/off, threshold, prompt). | These files stay on your computer. The publisher cannot see them. ### Claude Code's files -- The app **reads** files that Claude Code already writes: each account's `.claude.json` (email, plan, cached usage), `settings.json`, transcripts, session status files, file-history backups, and plugin and skill lists. It also asks `git` (and `gh`) about the folder you work in: branch, upstream, remote, your git user name and email, and your GitHub account name. It shows them in the top bar and sends them nowhere. +- The app **reads** files that Claude Code already writes: each account's `.claude.json` (email, plan, cached usage), `settings.json`, transcripts, session status files, file-history backups, and plugin and skill lists. The usage popup (click the usage meters) adds up token counts from the transcripts, and the `/` list in the compose box reads the installed plugins' commands and skills. Both stay on your computer. It also asks `git` (and `gh`) about the folder you work in: branch, upstream, remote, your git user name and email, and your GitHub account name. It shows them in the top bar and sends them nowhere. - The app **writes** to Claude Code's files only when you act: - **Save** in the Settings window writes the `settings.json` and `CLAUDE.md` files you changed. - A server's folder switch in Plugins & connectors changes the list of turned-off servers for that folder in `.claude.json`. @@ -94,7 +95,7 @@ These files stay on your computer. The publisher cannot see them. ## Delete your data 1. Uninstall from **Windows Settings > Apps > Installed apps**. This removes the program. -2. Uninstalling does **not** remove `%LOCALAPPDATA%\CCWrapper`. To delete it, close the app, type `%LOCALAPPDATA%\CCWrapper` in File Explorer's address bar, and delete the folder. Browser cookies, pasted images and backups go with it. +2. Uninstalling does **not** remove `%LOCALAPPDATA%\CCWrapper`. To delete it, close the app, type `%LOCALAPPDATA%\CCWrapper` in File Explorer's address bar, and delete the folder. Browser cookies, pasted images, drafts and backups go with it. 3. Claude Code's own folders (for example `%USERPROFILE%\.claude`) belong to Claude Code. The app does not remove them. ## Children diff --git a/README.md b/README.md index 3e9d869..9b61e45 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ![Platform](https://img.shields.io/badge/platform-Windows%2010%20%7C%2011-0078D6) ![.NET](https://img.shields.io/badge/.NET-10-512BD4) ![License](https://img.shields.io/badge/license-GPL--3.0-blue) -![Tests](https://img.shields.io/badge/tests-445%20passing-brightgreen) +![Tests](https://img.shields.io/badge/tests-497%20passing-brightgreen) Claude Code in a bare PowerShell window works, but it's clumsy. There's no search, you can't paste screenshots or files with Ctrl+V, you can't see your other sessions, and context, usage, file changes and subagents are hard to keep an eye on. If you also juggle **several Claude Code accounts** (work and personal, for example), it gets worse. @@ -82,8 +82,9 @@ It stays light: a native WPF app (no Electron), Windows' own icon font, a dark s **Compose box** (optional, under the terminal) - A real multi-line editor with a round send button: Enter sends, Shift+Enter adds a line, and Up recalls earlier prompts. - When Claude Code suggests your next prompt, the empty box shows it greyed too, as the terminal does: Tab takes it to edit, Enter sends it, typing replaces it. -- Pasted screenshots become image files. -- The **+** button adds files or folders (as a drop would) and opens the slash commands. Typing `/` lists Claude Code's commands, your custom commands and skills, narrowing as you type: Up/Down choose, Enter or Tab complete it without sending. +- Pasted screenshots become image files in `%LOCALAPPDATA%\CCWrapper\paste`; those older than 7 days are deleted when the app starts. +- The **+** button adds files or folders (as a drop would) and opens the slash commands. Typing `/` (or a `/` word after a space or line break) lists Claude Code's commands, your custom commands and skills, and those of your enabled plugins and claude.ai synced plugins (not MCP prompts), narrowing as you type: Up/Down choose, Enter runs it, Tab completes it without sending (after a space or line break, Enter only completes). Text that starts with a known command shows in blue. A slash command goes in as short pastes (at most 500 characters and one line break each), so Claude Code runs it instead of folding it into `[Pasted text]`. +- Each session keeps its own draft: switching tabs leaves it as it is, and drafts are saved when the app exits, restarts to update, or loses focus once it knows an update is ready to install, and come back when the session is reopened; a draft nobody changed for 14 days, or whose session you delete, is dropped. An update restart started from another window can still lose the drafts of a window that has not noticed the update. - It never types into a permission prompt or a dialog. Those you always answer in the terminal. Confirmations, warnings and errors use the app's dark theme too. @@ -95,7 +96,7 @@ Confirmations, warnings and errors use the app's dark theme too. **Bottom bar** - Model, effort and permission-mode switches that drive Claude Code's own `/model`, `/effort` and Shift+Tab. -- Meters with small bars for the context window and the 5-hour and weekly plan usage (amber from 80%), plus session tokens and cost. Usage shows even with no session open, is checked when the window opens and every 30 minutes, and the ↻ button checks it now. Hover the usage meters for when the 5-hour and weekly windows reset. +- Meters with small bars for the context window and the 5-hour and weekly plan usage (amber from 80%), plus session tokens and cost. Usage shows even with no session open, is checked when the window opens and every 30 minutes, and the ↻ button checks it now. Hover the usage meters for when the 5-hour and weekly windows reset; click them for a popup with both windows' bars and resets, and the tokens (in, out, cache) by model and by hour of day, counted from the account's transcripts. - **Compact hint:** once the context passes a threshold (50% by default; set it in Settings), a *Compact* button appears left of the context meter. It puts `/compact` and your keep/drop prompt in the message box and focuses it, but never sends it, and leaves text you already typed alone. ## Requirements @@ -126,7 +127,7 @@ Get-FileHash .\PieMH.CCWrapper-win-Setup.exe -Algorithm SHA256 # compare with gh attestation verify .\PieMH.CCWrapper-win-Setup.exe -R PieMH/CCWrapper ``` -**Uninstall** in Windows **Settings → Apps → Installed apps → CCWrapper**. That leaves your data in `%LOCALAPPDATA%\CCWrapper` (settings backups, pasted images, browser profiles, `app.json`); delete that folder to remove it. Claude Code's own files are never touched. +**Uninstall** in Windows **Settings → Apps → Installed apps → CCWrapper**. That leaves your data in `%LOCALAPPDATA%\CCWrapper` (settings backups, recent pasted images, browser profiles, `app.json`); delete that folder to remove it. Claude Code's own files are never touched. ### Build from source @@ -179,13 +180,13 @@ A few tips: CCWrapper is a viewer and a launcher. It doesn't touch your logins. - **It ends a `claude` process only when you confirm it:** Resume here's "Close it there and resume it here?" ends that terminal's `claude`, as closing the terminal would. Nothing else is ever stopped for you. -- **It reads only files Claude Code already writes:** each account's `.claude.json` (email, plan, cached usage), `settings.json`, transcripts, session status files, file-history backups, and plugin and skill lists. +- **It reads only files Claude Code already writes:** each account's `.claude.json` (email, plan, cached usage), `settings.json`, transcripts (also for the usage popup), session status files, file-history backups, and plugin and skill lists (also for the `/` list). - **It never reads** `.credentials.json` and never calls Anthropic APIs. Plugin and MCP changes you make in Plugins & connectors are done by Claude Code's own `claude plugin` and `claude mcp` commands. - **It edits Claude Code's files only from the Settings window, when you click Save:** the `settings.json` and `CLAUDE.md` files you changed there. A file that changed on disk since the window read it is not overwritten, and the previous version of each saved file is kept in `%LOCALAPPDATA%\CCWrapper\backups`. - **It deletes a session's files only when you pick Delete on its right-click menu and confirm:** the transcript `projects\\.jsonl` and its `\` folder, for good. A session that is open anywhere is refused, and nothing outside those two paths is touched. - **It edits `.claude.json` only when you flip a server's folder switch** in Plugins & connectors, the one change there that no `claude` command makes. Like Claude Code itself, it takes the file's lock, reads the file as it is at that moment, changes that folder's list of servers turned off and writes it back; the previous version goes to the same backups folder. - **Check health** in Plugins & connectors runs `claude mcp list`, which connects to every MCP server (and starts the local ones) to see if it answers, as `/mcp` does. -- **Otherwise it writes only to** `%LOCALAPPDATA%\CCWrapper`: pasted images, the browser profiles, settings backups, `app.json` (CCWrapper's own settings: your update choice, the compact hint and the browser choice), and the live status data described next. +- **Otherwise it writes only to** `%LOCALAPPDATA%\CCWrapper`: pasted images (deleted after 7 days), the browser profiles (a Browser cache over 100 MB is cleared at start; cookies stay until you clear them), settings backups, `app.json` (CCWrapper's own settings: your update choice, the compact hint and the browser choice), and the live status data described next. - **The status line:** if an account has no `statusLine` of its own, each session gets a tiny silent relay (`CCWrapper.StatusRelay.exe`) through `--settings`, which feeds the bottom bar. If you have your own status line, it's left alone. - **The Browser tool:** each session also gets that relay as a local MCP server (`ccwrapper-browser`, through `--mcp-config`), and a line in its system prompt (`--append-system-prompt`) to prefer it. The server talks only to its own CCWrapper window, through a local pipe that only your Windows user can open. Pages Claude opens load in the Browser panel's profile for the account (all tabs share it), with your logins, and Claude Code asks permission per tool as it does for any MCP tool. Its scripts and DevTools act with those logins, but DevTools refuses cookie access, downloads, local files, all simulated input (keyboard, mouse, touch and drag), the page history and scripts that persist across pages. Claude opens only http, https, blob, about:blank and data addresses, not local files, and its tools refuse to work on a page that is not a web address. The same holds for every tab. Turn it off in Settings → CCWrapper. - **Its only network call is the update check,** and only in an installed copy, only after you said yes on first start; the switch is in **About**. It asks GitHub for the latest release and downloads it; GitHub sees your IP address and the app version, nothing else is sent. The Browser panel loads only what you or Claude open in it. Details in [PRIVACY.md](PRIVACY.md). @@ -222,7 +223,7 @@ Every step is in the history: `git log --first-parent` shows the merge of each t - [xterm.js](https://xtermjs.org/) 6 (the VS Code terminal) in WebView2; - [Porta.Pty](https://www.nuget.org/packages/Porta.Pty) for ConPTY; - [DiffPlex](https://github.com/mmanela/diffplex) for diffs; -- xUnit, with 445 tests on synthetic fixtures. +- xUnit, with 497 tests on synthetic fixtures. ## Development @@ -234,7 +235,7 @@ dotnet test ``` src/CCWrapper/ WPF app (Theme.xaml: colors and styles, App.xaml: Fluent recolored) - Core/ accounts, paths, the LiveSession contract + Core/ accounts, paths, the LiveSession contract, compose-box drafts Data/ read-only parsers for Claude Code's files (all tolerant of unknown fields) Terminal/ ConPTY session, WebView2 + xterm.js host, paste, hidden /usage, process helpers Views/ picker, main window, tab bar, sidebar, panels, bottom bar, compose box diff --git a/src/CCWrapper/App.xaml.cs b/src/CCWrapper/App.xaml.cs index c91cf05..ff5b73f 100644 --- a/src/CCWrapper/App.xaml.cs +++ b/src/CCWrapper/App.xaml.cs @@ -17,8 +17,11 @@ protected override async void OnStartup(StartupEventArgs e) Shutdown(await Smoke.RunAsync()); return; } + _ = Task.Run(Housekeeping.Run); // old pasted images and status-line files, off the UI thread // One stray exception must not take every session down with the process (job objects). DispatcherUnhandledException += OnUnhandled; + // Another window's "Restart now" force-stops this process without OnExit, and the user must activate that window first. + Deactivated += (_, _) => { if (AppUpdate.ReadyVersion is not null) DraftStore.SaveAll(); }; TaskScheduler.UnobservedTaskException += (_, t) => t.SetObserved(); var i = Array.FindIndex(e.Args, a => a.Equals("--account", StringComparison.OrdinalIgnoreCase)); var account = i >= 0 && i + 1 < e.Args.Length ? AccountStore.Find(e.Args[i + 1]) : null; @@ -35,6 +38,7 @@ bool AskUpdateCheck() => Dialog.Show(Windows.OfType().FirstOrDefault(), protected override void OnExit(ExitEventArgs e) { + DraftStore.SaveAll(); AppUpdate.ApplyOnExit(); base.OnExit(e); } diff --git a/src/CCWrapper/Core/AppUpdate.cs b/src/CCWrapper/Core/AppUpdate.cs index ec4c1d5..7568668 100644 --- a/src/CCWrapper/Core/AppUpdate.cs +++ b/src/CCWrapper/Core/AppUpdate.cs @@ -121,7 +121,9 @@ static async Task CheckAsync() /// Exits at once, installs and starts the new version in 's window. Callers confirmed first. public static void RestartNow(string? accountLetter) { - if (_ready is not null && AppPrefs.ReadUpdateCheck() == true) _mgr?.ApplyUpdatesAndRestart(_ready, RestartArgs(accountLetter)); + if (_ready is null || AppPrefs.ReadUpdateCheck() != true) return; + DraftStore.SaveAll(); // the process ends inside the call below, without App.OnExit + _mgr?.ApplyUpdatesAndRestart(_ready, RestartArgs(accountLetter)); } /// On exit: hands a downloaded update to the updater, unless another window of this install still runs. diff --git a/src/CCWrapper/Core/DraftStore.cs b/src/CCWrapper/Core/DraftStore.cs new file mode 100644 index 0000000..fe7474f --- /dev/null +++ b/src/CCWrapper/Core/DraftStore.cs @@ -0,0 +1,104 @@ +using System.IO; +using System.Text.Json; +using CCWrapper.Terminal; + +namespace CCWrapper.Core; + +public sealed record Draft(string Text, int Caret); + +/// The compose box's unsent text per Claude Code session, kept in memory while the app runs and written to +/// %LocalAppData%\CCWrapper\drafts-<letter>.json (one file per account, so windows of two accounts never clobber each other) +/// only by (on exit, before an update restart, and when the app is deactivated while an update is ready to install). Keyed by the session id, which survives a resume; a tab that has none yet by its +/// tab id, and that draft moves to the session id once it appears. A draft not changed for is dropped, and +/// drops a deleted session's. A missing or broken file reads as no drafts. +/// shortcut: two windows of the same account overwrite each other's file, last exit wins; merge on write if that happens. +public sealed class DraftStore +{ + public const int Cap = 100; + public static readonly TimeSpan MaxAge = TimeSpan.FromDays(14); + const string TabPrefix = "tab:"; + static readonly Dictionary Stores = []; // UI thread only + + readonly string _path; + readonly OrderedDictionary _drafts = []; // oldest edit first + readonly Dictionary _changed = []; // UTC of each draft's last change + readonly Dictionary _lastKey = []; // per tab: the key and draft it last wrote, so a changed session id (/clear, fork) leaves no copy behind + + public DraftStore(string path) { _path = path; Load(); } + + public static DraftStore For(Account account) => Stores.TryGetValue(account.Letter, out var s) + ? s : Stores[account.Letter] = new(Path.Combine(ClaudeLaunch.AppDataDir, $"drafts-{account.Letter}.json")); + + /// Writes every store that was opened; call on exit. + public static void SaveAll() { foreach (var s in Stores.Values) s.Save(); } + + static string TabKey(LiveSession s) => TabPrefix + s.TabId; + + public Draft? Get(LiveSession s) + { + if (s.SessionId is { } id) + { + if (_drafts.TryGetValue(id, out var d)) return d; + if (_drafts.Remove(TabKey(s), out var byTab)) { _drafts[id] = byTab; _changed[id] = _changed.GetValueOrDefault(TabKey(s), DateTime.UtcNow); _changed.Remove(TabKey(s)); return byTab; } // the tab got its id after the draft was stashed + return null; + } + return _drafts.GetValueOrDefault(TabKey(s)); + } + + /// Stores the draft of ; an empty one removes it. + public void Set(LiveSession s, string text, int caret) + { + var tab = TabKey(s); + var key = s.SessionId ?? tab; + // The same text again (a tab switch, a caret move) is no edit: the 14 days count from the last real change. + var at = _drafts.TryGetValue(key, out var same) && same.Text == text && _changed.TryGetValue(key, out var was) ? was : DateTime.UtcNow; + Drop(tab); + // Only the draft this tab wrote: another tab may have resumed the old id and edited it since. + if (_lastKey.TryGetValue(s.TabId, out var old) && old.Key != key && _drafts.TryGetValue(old.Key, out var cur) && ReferenceEquals(cur, old.Draft)) Drop(old.Key); + Drop(key); + Draft? draft = text.Length > 0 ? new(text, caret) : null; + if (draft is not null) { _drafts[key] = draft; _changed[key] = at; } + _lastKey[s.TabId] = (key, draft); + } + + /// Drops the draft of a session that was deleted. + public void Remove(string sessionId) => Drop(sessionId); + + void Drop(string key) { _drafts.Remove(key); _changed.Remove(key); } + + static bool Expired(DateTime changedUtc) => DateTime.UtcNow - changedUtc > MaxAge; + + /// Atomic (temp file, then replace). Tab-id drafts are dropped (meaningless next run), the oldest beyond + /// too. Never throws: losing a draft must not take the app down on exit. + public void Save() + { + try + { + var rows = _drafts.Where(d => !d.Key.StartsWith(TabPrefix) && d.Value.Text.Length > 0 && !Expired(_changed.GetValueOrDefault(d.Key, DateTime.UtcNow))).TakeLast(Cap) + .Select(d => new Row(d.Key, d.Value.Text, d.Value.Caret, _changed.GetValueOrDefault(d.Key, DateTime.UtcNow))).ToList(); + Directory.CreateDirectory(Path.GetDirectoryName(_path)!); + var temp = _path + ".tmp"; + File.WriteAllText(temp, JsonSerializer.Serialize(rows)); + File.Move(temp, _path, overwrite: true); + } + catch (Exception) { } + } + + record Row(string Key, string Text, int Caret, DateTime? Changed = null); // no Changed (an older file): as if changed now + + void Load() + { + try + { + if (File.Exists(_path) && JsonSerializer.Deserialize>(File.ReadAllText(_path)) is { } rows) + foreach (var r in rows) + { + var at = r.Changed?.ToUniversalTime() ?? DateTime.UtcNow; + if (r is not { Key.Length: > 0, Text.Length: > 0 } || Expired(at)) continue; + _drafts[r.Key] = new(r.Text, Math.Max(0, r.Caret)); + _changed[r.Key] = at; + } + } + catch (Exception) { } // broken file: no drafts + } +} diff --git a/src/CCWrapper/Core/Housekeeping.cs b/src/CCWrapper/Core/Housekeeping.cs new file mode 100644 index 0000000..c06800d --- /dev/null +++ b/src/CCWrapper/Core/Housekeeping.cs @@ -0,0 +1,61 @@ +using System.IO; +using CCWrapper.Data; +using CCWrapper.Terminal; + +namespace CCWrapper.Core; + +/// Cleans up what the app itself wrote under %LOCALAPPDATA%\CCWrapper, by age. Best effort: nothing here throws, +/// and nothing outside the folder it is given is touched. Not touched at all: the terminal profiles, backups, app.json. +public static class Housekeeping +{ + public static readonly TimeSpan PasteMaxAge = TimeSpan.FromDays(7); + /// What a crash left behind goes after this; a file another window (account) owns is only deleted once it was not written for this long. + public static readonly TimeSpan LiveMaxAge = TimeSpan.FromDays(1); + public const long CacheLimit = 100L << 20; + + /// Once at app start, off the UI thread. + public static void Run() + { + var now = DateTime.UtcNow; + DeleteOlderThan(Path.Combine(ClaudeLaunch.AppDataDir, "paste"), PasteHandler.FilePattern, PasteMaxAge, now); + DeleteOlderThan(StatusLineFeed.LiveDir, "*.json", LiveMaxAge, now); + } + + /// Deletes the files of (not its subfolders) matching that were last written more than + /// before . Returns how many went. + public static int DeleteOlderThan(string dir, string pattern, TimeSpan age, DateTime now) + { + var deleted = 0; + try + { + foreach (var file in new DirectoryInfo(dir).EnumerateFiles(pattern)) + { + try + { + if (now - file.LastWriteTimeUtc <= age) continue; + file.Delete(); + deleted++; + } + catch (Exception) { } // in use or gone: the next start tries again + } + } + catch (Exception) { } // no such folder + return deleted; + } + + /// Size of the WebView2 profile's HTTP and code caches (what ClearBrowsingDataAsync(DiskCache) clears); 0 when there are none. + public static long CacheBytes(string profile) + { + long total = 0; + foreach (var name in new[] { "Cache", "Code Cache" }) + { + try + { + total += new DirectoryInfo(Path.Combine(profile, "EBWebView", "Default", name)) + .EnumerateFiles("*", SearchOption.AllDirectories).Sum(f => f.Length); + } + catch (Exception) { } + } + return total; + } +} diff --git a/src/CCWrapper/Data/PluginData.cs b/src/CCWrapper/Data/PluginData.cs index 11120c0..d858ff4 100644 --- a/src/CCWrapper/Data/PluginData.cs +++ b/src/CCWrapper/Data/PluginData.cs @@ -59,7 +59,7 @@ public sealed record PluginOption(string Key, string Title, string? Description, public sealed record CliOutcome(string? Message, string? FailureCode, string? Command, string? Sha256); /// "user", or the plugin the skill comes with. -public sealed record SkillRow(string Name, string Description, string Source); +public sealed record SkillRow(string Name, string Description, string Source, bool UserInvocable = true); /// Parses what claude plugin … prints. Pure, except . Every field is optional: /// these formats are Claude Code's own and change with its releases. @@ -183,8 +183,9 @@ public static List ReadSkills(string dir, string source) { var file = Path.Combine(skillDir, "SKILL.md"); if (!File.Exists(file)) continue; - var (name, description) = Frontmatter(File.ReadAllText(file)); - rows.Add(new(name ?? Path.GetFileName(skillDir), description ?? "", source)); + var text = File.ReadAllText(file); + var (name, description) = Frontmatter(text); + rows.Add(new(name ?? Path.GetFileName(skillDir), description ?? "", source, UserInvocable(text))); } } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) @@ -213,6 +214,17 @@ public static (string? Name, string? Description) Frontmatter(string markdown) return (name, description); } + /// False when the leading --- block says user-invocable: false: Claude Code keeps such a skill or + /// command out of the / menu. + public static bool UserInvocable(string markdown) + { + var lines = markdown.ReplaceLineEndings("\n").Split('\n'); + if (lines[0].Trim() != "---") return true; + for (var i = 1; i < lines.Length && lines[i].Trim() != "---"; i++) + if (Regex.IsMatch(lines[i], @"^user-invocable:\s*[""']?false[""']?\s*$", RegexOptions.IgnoreCase)) return false; + return true; + } + static DateTimeOffset? Date(JsonElement e) => e.Str() is { } s && DateTimeOffset.TryParse(s, out var d) ? d : null; static IEnumerable Strings(JsonElement e) => diff --git a/src/CCWrapper/Data/SlashCommands.cs b/src/CCWrapper/Data/SlashCommands.cs index 3f9f206..1b3663f 100644 --- a/src/CCWrapper/Data/SlashCommands.cs +++ b/src/CCWrapper/Data/SlashCommands.cs @@ -1,4 +1,6 @@ using System.IO; +using System.Text.Json; +using System.Text.RegularExpressions; using CCWrapper.Core; namespace CCWrapper.Data; @@ -7,7 +9,9 @@ namespace CCWrapper.Data; public sealed record SlashCommand(string Name, string Description, string Source); /// The slash commands the compose box offers: Claude Code's built-ins, custom commands (commands\**\*.md) and -/// skills (skills\*\SKILL.md), each from the account's config folder and the project's .claude folder. +/// skills (skills\*\SKILL.md), each from the account's config folder and the project's .claude folder, and those +/// of the enabled plugins (plugin:name). shortcut: MCP prompts (/mcp__server__prompt) are left out, no file Claude +/// Code writes lists them. public static class SlashCommands { static SlashCommand B(string name, string description) => new(name, description, "built-in"); @@ -41,9 +45,9 @@ public static List List(Account account, string? cwd) foreach (var (dir, source) in roots) { found.AddRange(Commands(Path.Combine(dir, "commands"), source)); - found.AddRange(PluginData.ReadSkills(Path.Combine(dir, "skills"), source + " skill") - .Select(s => new SlashCommand(s.Name, One(s.Description), s.Source))); + found.AddRange(Skills(Path.Combine(dir, "skills"), source + " skill", "")); } + found.AddRange(Plugins(account, cwd)); found.AddRange(BuiltIn); return found.Where(c => c.Name.Length > 0).DistinctBy(c => c.Name, StringComparer.OrdinalIgnoreCase) .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList(); @@ -59,8 +63,97 @@ public static List Filter(IEnumerable all, string qu return [.. starts, .. rows.Where(c => !starts.Contains(c) && c.Name.Contains(query, StringComparison.OrdinalIgnoreCase))]; } + static IEnumerable Skills(string dir, string source, string prefix) => + PluginData.ReadSkills(dir, source).Where(s => s.UserInvocable).Select(s => new SlashCommand(prefix + s.Name, One(s.Description), s.Source)); + + // The enabled plugins of installed_plugins.json: "enabledPlugins" of settings.json, project, then local settings, later wins. + // Of several installs of one plugin the project one for this cwd wins, then the user one. + static List Plugins(Account account, string? cwd) + { + var rows = new List(); + var enabled = new Dictionary(); + var settings = new List { Path.Combine(account.ConfigRoot, "settings.json") }; + if (cwd is { Length: > 0 }) settings.AddRange([Path.Combine(cwd, ".claude", "settings.json"), Path.Combine(cwd, ".claude", "settings.local.json")]); + foreach (var file in settings) + foreach (var (id, on) in ReadJson(file, r => r.Prop("enabledPlugins") is { ValueKind: JsonValueKind.Object } e + ? e.EnumerateObject().Select(p => (p.Name, p.Value.ValueKind == JsonValueKind.True)).ToList() : []) ?? []) + enabled[id] = on; + var installed = ReadJson(Path.Combine(account.ConfigRoot, "plugins", "installed_plugins.json"), r => r.Prop("plugins") is { ValueKind: JsonValueKind.Object } p + ? p.EnumerateObject().Where(e => e.Value.ValueKind == JsonValueKind.Array && enabled.GetValueOrDefault(e.Name)) + .Select(e => (e.Name, Paths: e.Value.EnumerateArray() + .OrderBy(i => Same(i.Prop("projectPath").Str(), cwd) ? 0 : i.Prop("scope").Str() == "user" ? 1 : 2) + .Select(i => i.Prop("installPath").Str()).OfType().ToList())).ToList() : []); + foreach (var (id, paths) in installed ?? []) + { + var root = paths.FirstOrDefault(Directory.Exists); + var name = id.LastIndexOf('@') is > 0 and var at ? id[..at] : id; + if (root is null || name.Length == 0) continue; + rows.AddRange(Commands(Path.Combine(root, "commands"), "plugin " + name, name + ":")); + rows.AddRange(Skills(Path.Combine(root, "skills"), "plugin " + name, name + ":")); + } + rows.AddRange(Synced(account, enabled)); + return rows; + } + + // claude.ai synced plugins: plugins\synced\\[~gN]\ with .meta.json beside it, and a manifest.json. "~gN" is a + // generation (plugin.json and the manifest name the plugin without it); the highest comes first and is the one kept. + // shortcut: every synced plugin seen has installation_preference "available" and is offered, so only a known off-value or an + // enabledPlugins false hides one; upgrade if Claude Code writes another vocabulary. + // The user-id folder is "_" of .claude.json's oauthAccount: when both are there only that one is + // read (an older login leaves its folder behind and Claude Code ignores it), else every folder is. + static readonly string[] SyncedOff = ["disabled", "hidden", "unavailable", "uninstalled"]; + + static List Synced(Account account, Dictionary enabled) + { + var rows = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var top = Path.Combine(account.ConfigRoot, "plugins", "synced"); + if (!Directory.Exists(top)) return rows; // no synced plugins: .claude.json stays unread + var current = ReadJson(account.GlobalConfigPath, r => r.Prop("oauthAccount") is { ValueKind: JsonValueKind.Object } o + ? (Org: o.Prop("organizationUuid").Str(), User: o.Prop("accountUuid").Str()) : default); + try + { + foreach (var userDir in Directory.EnumerateDirectories(top)) + { + if (current is { Org.Length: > 0, User.Length: > 0 } + && !Path.GetFileName(userDir).Equals($"{current.Org}_{current.User}", StringComparison.OrdinalIgnoreCase)) continue; + var manifest = ReadJson(Path.Combine(userDir, "manifest.json"), r => r.Prop("plugins") is { ValueKind: JsonValueKind.Array } a + ? a.EnumerateArray().Select(e => (Id: e.Prop("pluginId").Str(), Name: e.Prop("name").Str(), Pref: e.Prop("installationPreference").Str(), + Market: e.Prop("marketplaceName").Str())).ToList() : []) ?? []; + foreach (var dir in Directory.EnumerateDirectories(userDir).OrderByDescending(d => Regex.Match(d, @"~g(\d+)$") is { Success: true } m ? int.Parse(m.Groups[1].Value) : 0)) + { + var meta = ReadJson(dir + ".meta.json", r => (Id: r.Prop("server_plugin_id").Str(), Pref: r.Prop("installation_preference").Str(), Market: r.Prop("marketplace_name").Str())); + var entry = manifest.FirstOrDefault(e => e.Id is { Length: > 0 } && e.Id == meta.Id); + var name = ReadJson(Path.Combine(dir, ".claude-plugin", "plugin.json"), r => r.Prop("name").Str()) is { Length: > 0 } n ? n + : entry.Name is { Length: > 0 } ? entry.Name : Regex.Replace(Path.GetFileName(dir), @"~g\d+$", ""); + var pref = meta.Pref ?? entry.Pref; + if (name.Length == 0 || !seen.Add(name) || SyncedOff.Contains(pref, StringComparer.OrdinalIgnoreCase) + || enabled.TryGetValue(name + "@" + (meta.Market ?? entry.Market), out var on) && !on) continue; + rows.AddRange(Commands(Path.Combine(dir, "commands"), "plugin " + name, name + ":")); + rows.AddRange(Skills(Path.Combine(dir, "skills"), "plugin " + name, name + ":")); + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } // fewer synced plugins + return rows; + } + + static bool Same(string? a, string? b) => a is { Length: > 0 } && b is { Length: > 0 } + && string.Equals(Path.GetFullPath(a).TrimEnd('\\', '/'), Path.GetFullPath(b).TrimEnd('\\', '/'), StringComparison.OrdinalIgnoreCase); + + // null when the file is missing, unreadable or not JSON. + static T? ReadJson(string file, Func read) + { + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(file)); + return read(doc.RootElement); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or ArgumentException) { return default; } + } + // A subfolder becomes "dir:name", as Claude Code names it. - static List Commands(string dir, string source) + static List Commands(string dir, string source, string prefix = "") { var rows = new List(); try @@ -70,8 +163,10 @@ static List Commands(string dir, string source) { try { - var name = Path.ChangeExtension(Path.GetRelativePath(dir, file), null).Replace('\\', ':').Replace('/', ':'); - rows.Add(new(name, Describe(File.ReadAllText(file)), source)); + var text = File.ReadAllText(file); + if (!PluginData.UserInvocable(text)) continue; + var name = prefix + Path.ChangeExtension(Path.GetRelativePath(dir, file), null).Replace('\\', ':').Replace('/', ':'); + rows.Add(new(name, Describe(text), source)); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } // this file is just missing } diff --git a/src/CCWrapper/Data/UsageStats.cs b/src/CCWrapper/Data/UsageStats.cs new file mode 100644 index 0000000..7b60ab3 --- /dev/null +++ b/src/CCWrapper/Data/UsageStats.cs @@ -0,0 +1,117 @@ +using System.Globalization; +using System.IO; +using System.Text.Json; +using CCWrapper.Core; +using static CCWrapper.Data.TranscriptReader.Get; + +namespace CCWrapper.Data; + +/// Token counts of an API message or a sum of them. leaves out cache reads (they re-read the +/// same context every turn and would drown everything else); they are shown on their own. +public readonly record struct TokenTotals(long Input, long Output, long CacheWrite, long CacheRead) +{ + public long Work => Input + Output + CacheWrite; + public static TokenTotals operator +(TokenTotals a, TokenTotals b) => new(a.Input + b.Input, a.Output + b.Output, a.CacheWrite + b.CacheWrite, a.CacheRead + b.CacheRead); +} + +/// Tokens of one window: the total, by model (biggest first, Model null = not recorded) and by local hour of day. +public sealed record WindowUsage(DateTimeOffset Start, TokenTotals Total, IReadOnlyList<(string? Model, long Work)> ByModel, long[] ByHour, int Messages); + +public sealed record UsageStatsResult(WindowUsage FiveHour, WindowUsage Week, int Files, TimeSpan Took); + +/// Token usage of an account over the 5 h and weekly windows, summed from the assistant records +/// (message.usage, message.model, timestamp) of the transcripts written since the weekly window began: +/// projects\*\*.jsonl and the subagents' projects\*\*\subagents\*.jsonl. Claude Code writes one record per +/// content block of an API message, so records are merged by message id (the largest count of each field wins). +/// Undocumented format: every field optional, bad lines skipped. Does file IO: off the UI thread. +public static class UsageStats +{ + static readonly TimeSpan Ttl = TimeSpan.FromSeconds(60); + static readonly Dictionary Cache = []; + + /// The window starts: a known reset in the future gives reset minus the window length, else the last 5 h / 7 d. + public static (DateTimeOffset Five, DateTimeOffset Week) Starts(DateTimeOffset? fiveReset, DateTimeOffset? weekReset, DateTimeOffset now) => + (fiveReset > now ? fiveReset.Value.AddHours(-5) : now.AddHours(-5), weekReset > now ? weekReset.Value.AddDays(-7) : now.AddDays(-7)); + + /// The last result for the account if younger than a minute, else computed. Never throws. + public static UsageStatsResult? Get(Account account, DateTimeOffset? fiveReset, DateTimeOffset? weekReset) + { + lock (Cache) + if (Cache.TryGetValue(account.Letter, out var c) && DateTime.UtcNow - c.At < Ttl) return c.Result; + try + { + var (five, week) = Starts(fiveReset, weekReset, DateTimeOffset.Now); + var result = Compute(account.Paths.Projects, five, week); + lock (Cache) Cache[account.Letter] = (DateTime.UtcNow, result); + return result; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } + + public static UsageStatsResult Compute(string projectsDir, DateTimeOffset fiveStart, DateTimeOffset weekStart) + { + var watch = System.Diagnostics.Stopwatch.StartNew(); + var seen = new Dictionary(); // by message id; ones without an id cannot be merged and go in the list + var anonymous = new List(); + var files = 0; + foreach (var file in Files(projectsDir, weekStart)) + { + files++; + try + { + foreach (var (line, _) in JsonlReader.ReadLines(file)) + { + // cheap pre-filter: most lines are user records, tool results and so on + if (!line.Contains("\"usage\"", StringComparison.Ordinal) || JsonlReader.TryParse(line) is not { } rec) continue; + if (Read(rec, weekStart) is not { } m) continue; + if (m.Id is null) anonymous.Add(m); + else seen[m.Id] = seen.TryGetValue(m.Id, out var old) ? old.Merge(m) : m; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } // a file being deleted or locked + } + var all = seen.Values.Concat(anonymous).ToList(); + return new(Window(all, fiveStart), Window(all, weekStart), files, watch.Elapsed); + } + + static IEnumerable Files(string projectsDir, DateTimeOffset since) + { + if (!Directory.Exists(projectsDir)) yield break; + var cut = since.UtcDateTime; + foreach (var f in new DirectoryInfo(projectsDir).EnumerateFiles("*.jsonl", SearchOption.AllDirectories)) + if (f.LastWriteTimeUtc >= cut) yield return f.FullName; // shortcut: any depth, so also subagents (\subagents\) and anything CC adds + } + + sealed record Msg(string? Id, DateTimeOffset At, string? Model, TokenTotals Tokens) + { + public Msg Merge(Msg o) => this with + { + At = o.At < At ? o.At : At, Model = Model ?? o.Model, + Tokens = new(Math.Max(Tokens.Input, o.Tokens.Input), Math.Max(Tokens.Output, o.Tokens.Output), + Math.Max(Tokens.CacheWrite, o.Tokens.CacheWrite), Math.Max(Tokens.CacheRead, o.Tokens.CacheRead)), + }; + } + + static Msg? Read(JsonElement rec, DateTimeOffset since) + { + var msg = Prop(rec, "message"); + if (Prop(msg, "usage") is not { ValueKind: JsonValueKind.Object } u) return null; + if (Str(rec, "timestamp") is not { } ts || !DateTimeOffset.TryParse(ts, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var at) || at < since) return null; + var model = Str(msg, "model"); + if (model == "") return null; // CC's own error stubs + long N(string k) => (long)(Num(u, k) ?? 0); + return new(Str(msg, "id"), at, model, new(N("input_tokens"), N("output_tokens"), N("cache_creation_input_tokens"), N("cache_read_input_tokens"))); + } + + static WindowUsage Window(List all, DateTimeOffset start) + { + var inside = all.Where(m => m.At >= start).ToList(); + var hours = new long[24]; + foreach (var m in inside) hours[m.At.ToLocalTime().Hour] += m.Tokens.Work; + var byModel = inside.GroupBy(m => m.Model).Select(g => (g.Key, g.Sum(m => m.Tokens.Work))).OrderByDescending(x => x.Item2).ToList(); + return new(start, inside.Aggregate(default(TokenTotals), (a, m) => a + m.Tokens), byModel, hours, inside.Count); + } +} diff --git a/src/CCWrapper/Terminal/PasteHandler.cs b/src/CCWrapper/Terminal/PasteHandler.cs index 86d3617..4b39703 100644 --- a/src/CCWrapper/Terminal/PasteHandler.cs +++ b/src/CCWrapper/Terminal/PasteHandler.cs @@ -30,6 +30,9 @@ public static class PasteHandler public static string? ForCompose(IDataObject data, string? dir = null) => Text(data) ?? (data.GetData(DataFormats.Bitmap) is BitmapSource image ? QuotePaths([SaveImage(image, dir)]) : null); + /// The names gives; deletes only files of this shape. + public const string FilePattern = "paste-*.png"; + /// Saves to %LOCALAPPDATA%\CCWrapper\paste (or ) and returns the file path. public static string SaveImage(BitmapSource image, string? dir = null) { diff --git a/src/CCWrapper/Terminal/Smoke.cs b/src/CCWrapper/Terminal/Smoke.cs index 6bc8fe7..5d84130 100644 --- a/src/CCWrapper/Terminal/Smoke.cs +++ b/src/CCWrapper/Terminal/Smoke.cs @@ -14,6 +14,8 @@ public static class Smoke /// Returns the process exit code: 0 on success. public static async Task RunAsync() { + // The terminal profile of the last run (see TerminalHost: no account means "smoke"); this run's can only go once the process ends, the next start takes it. + try { Directory.Delete(Path.Combine(ClaudeLaunch.AppDataDir, "WebView2", "smoke"), recursive: true); } catch (Exception) { } try { return await EchoAsync() == 0 && await BrowserAsync() && await PanelAsync() ? 0 : 1; diff --git a/src/CCWrapper/Theme.xaml b/src/CCWrapper/Theme.xaml index 11d98b2..1182edd 100644 --- a/src/CCWrapper/Theme.xaml +++ b/src/CCWrapper/Theme.xaml @@ -18,6 +18,8 @@ + + @@ -512,4 +514,22 @@ + + + + diff --git a/src/CCWrapper/Views/BottomBar.xaml b/src/CCWrapper/Views/BottomBar.xaml index 08dc541..19239f1 100644 --- a/src/CCWrapper/Views/BottomBar.xaml +++ b/src/CCWrapper/Views/BottomBar.xaml @@ -41,14 +41,24 @@ - - - - - - - - + + + + + + + +