From b11e1a31778748f886afc2bd41702d8be591720f Mon Sep 17 00:00:00 2001 From: PieMH Date: Tue, 20 Oct 2026 19:26:55 +0200 Subject: [PATCH 01/12] Compose box: Enter in the / list runs the command; a long or multi-line slash command goes in as short pastes so Claude Code runs it instead of folding it into pasted text --- README.md | 2 +- src/CCWrapper/Views/ComposeBox.xaml.cs | 34 ++++++++++++++++++++--- tests/CCWrapper.Tests/Input/InputTests.cs | 31 +++++++++++++++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3e9d869..e923d1b 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ It stays light: a native WPF app (no Electron), Windows' own icon font, a dark s - 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. +- 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 runs it, Tab completes it without sending. - 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. diff --git a/src/CCWrapper/Views/ComposeBox.xaml.cs b/src/CCWrapper/Views/ComposeBox.xaml.cs index 7a62f57..b8f9ce7 100644 --- a/src/CCWrapper/Views/ComposeBox.xaml.cs +++ b/src/CCWrapper/Views/ComposeBox.xaml.cs @@ -128,7 +128,7 @@ void MoveSlash(int by) SlashList.ScrollIntoView(SlashList.SelectedItem); } - // Completes to "/name " (the rest of the box stays); never sends. + // Completes to "/name " (the rest of the box stays); sending is the caller's. void CompleteSlash(SlashCommand command) { var insert = $"/{command.Name} "; @@ -258,7 +258,12 @@ void OnKeyDown(object sender, KeyEventArgs e) e.Handled = true; // before the prompt history and sending if (e.Key == Key.Escape) CloseSlash(); else if (e.Key is Key.Up or Key.Down) MoveSlash(e.Key == Key.Up ? -1 : 1); - else if (SlashList.SelectedItem is SlashCommand c) CompleteSlash(c); + else if (SlashList.SelectedItem is SlashCommand c) + { + var send = e.Key == Key.Enter && !_forced; // Enter runs the chosen command, as in the TUI; Tab, or a command put into text, only completes + CompleteSlash(c); + if (send && SendButton.IsEnabled) _ = SendAsync(); + } } else if (e.Key == Key.Enter && (mods & ModifierKeys.Shift) == 0) { @@ -401,7 +406,7 @@ public static void SetSwitching(LiveSession s, bool on) } } - /// One bracketed paste, then Enter after (an Enter sent with the paste would be taken as + /// One bracketed paste (a slash command: see ), then Enter after (an Enter sent with the paste would be taken as /// part of it). The registry is read again right before each. Any text is refused while the TUI's input is in bash mode /// (it would run as a shell command), and a slash command while it holds text. Null when it went in, else why not. public static async Task SubmitAsync(LiveSession? s, string text) @@ -421,12 +426,33 @@ public static void SetSwitching(LiveSession s, bool on) if (text.StartsWith('/') && HasPromptText(tail)) return ClearInputFirst; } if (await FreshBlockedReasonAsync(s) is { } before) return before; - if (!s.SendText(text)) return BlockedReason(s) ?? "Not sent"; + foreach (var paste in Pastes(text)) + if (!s.SendText(paste)) return BlockedReason(s) ?? "Not sent"; await Task.Delay(EnterDelay(text.Length)); if (await FreshBlockedReasonAsync(s) is { } after) return after + ". The text is in the terminal input, not sent."; return s.SendKeys("\r") ? null : BlockedReason(s) ?? "Not sent"; } + internal const int PasteChunk = 500; + + /// The pastes goes in as: one, but a slash command as several of at most + /// characters and one line break each (at its start). Claude Code folds a paste over 800 + /// characters or 3 lines into "[Pasted text #1]", and a folded slash command is sent as a plain prompt. + /// shortcut: a terminal under about 12 rows may fold even a one-break paste; send breaks some other way if that shows up. + internal static IEnumerable Pastes(string text) + { + if (!text.StartsWith('/')) { yield return text; yield break; } + text = text.ReplaceLineEndings("\r"); + for (var at = 0; at < text.Length;) + { + var end = Math.Min(text.Length, at + PasteChunk); + if (text.IndexOf('\r', at + 1, end - at - 1) is >= 0 and var line) end = line; + else if (end < text.Length && char.IsHighSurrogate(text[end - 1])) end--; // never split a surrogate pair + yield return text[at..end]; + at = end; + } + } + /// A long paste takes the TUI longer to take in: 80 ms + 1 ms per 100 characters, at most 1 s. public static TimeSpan EnterDelay(int length) => TimeSpan.FromMilliseconds(Math.Min(1000, 80 + length / 100)); diff --git a/tests/CCWrapper.Tests/Input/InputTests.cs b/tests/CCWrapper.Tests/Input/InputTests.cs index f3844fa..4c86183 100644 --- a/tests/CCWrapper.Tests/Input/InputTests.cs +++ b/tests/CCWrapper.Tests/Input/InputTests.cs @@ -52,6 +52,37 @@ public async Task Submit_pastes_then_presses_enter() Assert.Equal(["text:/help", "keys:\r"], Sent(s)); } + [Fact] + public void A_prompt_is_one_paste_whatever_its_size() + { + var long_ = string.Concat(Enumerable.Repeat("line\r\n", 400)); + Assert.Equal([long_], TuiInput.Pastes(long_)); + } + + [Fact] + public void A_slash_command_goes_in_as_pastes_Claude_Code_does_not_fold() + { + var args = string.Concat(Enumerable.Range(0, 400).Select(i => $"w{i} ")) + "\r\nsecond\nthird\r\n\r\nlastπŸ˜€" + new string('x', 1200); + var text = "/agents-team:agents-team " + args; + var pastes = TuiInput.Pastes(text).ToList(); + Assert.Equal(text.ReplaceLineEndings("\r"), string.Concat(pastes)); // nothing lost, newlines as CR as in a paste + Assert.StartsWith("/agents-team:agents-team w0 ", pastes[0]); + Assert.All(pastes, p => Assert.InRange(p.Length, 1, TuiInput.PasteChunk)); + Assert.All(pastes, p => Assert.True(p.Count(c => c == '\r') <= 1 && p.IndexOf('\r') <= 0)); // at most one break, at the start + Assert.All(pastes, p => Assert.False(char.IsHighSurrogate(p[^1]))); + Assert.Equal(["/help"], TuiInput.Pastes("/help")); + Assert.Equal(["/x", "\r", "\ry"], TuiInput.Pastes("/x\n\ny")); + Assert.Equal(["/" + new string('a', 498), "πŸ˜€b"], TuiInput.Pastes("/" + new string('a', 498) + "πŸ˜€b")); + } + + [Fact] + public async Task Submit_sends_a_long_slash_command_as_several_pastes_then_one_enter() + { + var s = Session(SessionStatus.Idle); + Assert.Null(await TuiInput.SubmitAsync(s, "/foo " + new string('a', 1000))); + Assert.Equal(["text:/foo " + new string('a', 495), "text:" + new string('a', 500), "text:aaaaa", "keys:\r"], Sent(s)); + } + [Fact] public async Task Submit_sends_nothing_when_blocked() { From 3b4d03b551d771b9d83f7833e5126404186271a5 Mon Sep 17 00:00:00 2001 From: PieMH Date: Tue, 20 Oct 2026 19:33:46 +0200 Subject: [PATCH 02/12] Slash list: commands and skills of enabled installed plugins and of claude.ai synced plugins; user-invocable: false entries hidden --- src/CCWrapper/Data/PluginData.cs | 18 ++- src/CCWrapper/Data/SlashCommands.cs | 101 ++++++++++++++- .../Data/SlashCommandsTests.cs | 118 ++++++++++++++++++ 3 files changed, 228 insertions(+), 9 deletions(-) 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..8ab4fb0 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,91 @@ 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.ConfigRoot, 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. + static readonly string[] SyncedOff = ["disabled", "hidden", "unavailable", "uninstalled"]; + + static List Synced(string configRoot, Dictionary enabled) + { + var rows = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var top = Path.Combine(configRoot, "plugins", "synced"); + try + { + if (!Directory.Exists(top)) return rows; + foreach (var userDir in Directory.EnumerateDirectories(top)) + { + 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 +157,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/tests/CCWrapper.Tests/Data/SlashCommandsTests.cs b/tests/CCWrapper.Tests/Data/SlashCommandsTests.cs index 764aebc..d31c483 100644 --- a/tests/CCWrapper.Tests/Data/SlashCommandsTests.cs +++ b/tests/CCWrapper.Tests/Data/SlashCommandsTests.cs @@ -72,4 +72,122 @@ public void List_tolerates_missing_folders_and_lets_project_then_user_win_over_b Assert.Equal("Project compact", list.Single(c => c.Name.Equals("compact", StringComparison.OrdinalIgnoreCase)).Description); Assert.Equal("User init", list.Single(c => c.Name == "init").Description); } + + // A plugin folder as installed_plugins.json points at it: commands\ and skills\. + static string Plugin(string root, string name, string version = "1.0.0") + { + var dir = Path.Combine(root, "plugins", "cache", name, version); + FakeHome.WriteFile(Path.Combine(dir, "commands", "run.md"), "---\ndescription: Run it\n---\n"); + FakeHome.WriteFile(Path.Combine(dir, "commands", "sub", "deep.md"), "Deep one"); + FakeHome.WriteFile(Path.Combine(dir, "commands", "hidden.md"), "---\nuser-invocable: false\n---\nHidden"); + FakeHome.WriteFile(Path.Combine(dir, "skills", "review", "SKILL.md"), "---\nname: review\ndescription: Reviews\ndisable-model-invocation: true\n---\n"); + FakeHome.WriteFile(Path.Combine(dir, "skills", "internal", "SKILL.md"), "---\nname: internal\nuser-invocable: false\n---\n"); + return dir; + } + + static string Installed(params (string Id, string Scope, string? ProjectPath, string Path)[] installs) => + System.Text.Json.JsonSerializer.Serialize(new + { + version = 2, + plugins = installs.GroupBy(i => i.Id).ToDictionary(g => g.Key, + g => g.Select(i => new { scope = i.Scope, projectPath = i.ProjectPath, installPath = i.Path }).ToArray()), + }); + + [Fact] + public void List_adds_the_commands_and_skills_of_enabled_plugins_only() + { + var account = _home.AddAccount("x"); + var root = account.ConfigRoot; + var on = Plugin(root, "on-plugin"); + var off = Plugin(root, "off-plugin"); + var unlisted = Plugin(root, "unlisted-plugin"); + FakeHome.WriteFile(Path.Combine(root, "plugins", "installed_plugins.json"), + Installed(("on-plugin@mkt", "user", null, on), ("off-plugin@mkt", "user", null, off), ("unlisted-plugin@mkt", "user", null, unlisted))); + FakeHome.WriteFile(Path.Combine(root, "settings.json"), """{"enabledPlugins":{"on-plugin@mkt":true,"off-plugin@mkt":false}}"""); + + var list = SlashCommands.List(account, _project).ToDictionary(c => c.Name); + + Assert.Equal("Run it", list["on-plugin:run"].Description); + Assert.Equal("plugin on-plugin", list["on-plugin:run"].Source); + Assert.Contains("on-plugin:sub:deep", list.Keys); + Assert.Equal("plugin on-plugin", list["on-plugin:review"].Source); + Assert.DoesNotContain("on-plugin:hidden", list.Keys); + Assert.DoesNotContain("on-plugin:internal", list.Keys); + Assert.DoesNotContain(list.Keys, k => k.StartsWith("off-plugin:") || k.StartsWith("unlisted-plugin:")); + } + + [Fact] + public void A_local_setting_enables_a_plugin_and_the_project_install_wins_for_its_folder() + { + var account = _home.AddAccount("x"); + var root = account.ConfigRoot; + var user = Plugin(root, "p", "1.0.0"); + var project = Plugin(root, "p", "2.0.0"); + FakeHome.WriteFile(Path.Combine(project, "commands", "only-in-project.md"), "x"); + FakeHome.WriteFile(Path.Combine(root, "plugins", "installed_plugins.json"), + Installed(("p@mkt", "user", null, user), ("p@mkt", "project", _project, project))); + FakeHome.WriteFile(Path.Combine(root, "settings.json"), """{"enabledPlugins":{"p@mkt":false}}"""); + Assert.DoesNotContain(SlashCommands.List(account, _project), c => c.Name.StartsWith("p:")); + + FakeHome.WriteFile(Path.Combine(_project, ".claude", "settings.local.json"), """{"enabledPlugins":{"p@mkt":true}}"""); + Assert.Contains(SlashCommands.List(account, _project), c => c.Name == "p:only-in-project"); + Assert.DoesNotContain(SlashCommands.List(account, null), c => c.Name.StartsWith("p:")); + FakeHome.WriteFile(Path.Combine(root, "settings.json"), """{"enabledPlugins":{"p@mkt":true}}"""); + Assert.DoesNotContain(SlashCommands.List(account, null), c => c.Name == "p:only-in-project"); + Assert.Contains(SlashCommands.List(account, null), c => c.Name == "p:run"); + } + + [Fact] + public void List_adds_claude_ai_synced_plugins_under_their_real_name() + { + var account = _home.AddAccount("x"); + var synced = Path.Combine(account.ConfigRoot, "plugins", "synced", "org_user"); + // "~g3" is a generation: the name comes from plugin.json, else the manifest, else the folder without the suffix. + FakeHome.WriteFile(Path.Combine(synced, "eng~g3", "skills", "code-review", "SKILL.md"), "---\nname: code-review\ndescription: Review code\n---\n"); + FakeHome.WriteFile(Path.Combine(synced, "eng~g3", ".claude-plugin", "plugin.json"), """{"name":"engineering"}"""); + FakeHome.WriteFile(Path.Combine(synced, "eng~g3.meta.json"), """{"server_plugin_id":"p1","installation_preference":"available"}"""); + FakeHome.WriteFile(Path.Combine(synced, "eng~g2", "skills", "old", "SKILL.md"), "---\nname: old\n---\n"); + FakeHome.WriteFile(Path.Combine(synced, "eng~g2", ".claude-plugin", "plugin.json"), """{"name":"engineering"}"""); + FakeHome.WriteFile(Path.Combine(synced, "viewer", "commands", "open.md"), "Open a PDF"); + FakeHome.WriteFile(Path.Combine(synced, "viewer.meta.json"), """{"server_plugin_id":"p2"}"""); + FakeHome.WriteFile(Path.Combine(synced, "nometa~g2", "skills", "s", "SKILL.md"), "---\nname: s\n---\n"); + FakeHome.WriteFile(Path.Combine(synced, "off", "commands", "x.md"), "Off"); + FakeHome.WriteFile(Path.Combine(synced, "off.meta.json"), """{"installation_preference":"disabled"}"""); + FakeHome.WriteFile(Path.Combine(synced, "manifest.json"), """{"plugins":[{"pluginId":"p2","name":"pdf-viewer","installationPreference":"available"}]}"""); + + var list = SlashCommands.List(account, null).ToDictionary(c => c.Name); + + Assert.Equal("plugin engineering", list["engineering:code-review"].Source); + Assert.Equal("Review code", list["engineering:code-review"].Description); + Assert.DoesNotContain("engineering:old", list.Keys); // the older generation is dropped, not merged + Assert.Contains("pdf-viewer:open", list.Keys); + Assert.Contains("nometa:s", list.Keys); + Assert.DoesNotContain(list.Keys, k => k.StartsWith("off:") || k.Contains('~')); + } + + [Fact] + public void An_installed_plugin_hides_a_synced_one_of_the_same_name() + { + var account = _home.AddAccount("x"); + var root = account.ConfigRoot; + var dir = Plugin(root, "legal"); + FakeHome.WriteFile(Path.Combine(root, "plugins", "installed_plugins.json"), Installed(("legal@mkt", "user", null, dir))); + FakeHome.WriteFile(Path.Combine(root, "settings.json"), """{"enabledPlugins":{"legal@mkt":true}}"""); + FakeHome.WriteFile(Path.Combine(root, "plugins", "synced", "u", "legal", "commands", "run.md"), "Synced run"); + Assert.Equal("Run it", SlashCommands.List(account, null).Single(c => c.Name == "legal:run").Description); + } + + [Theory] + [InlineData(null)] + [InlineData("not json {")] + [InlineData("[]")] + [InlineData("""{"plugins":{"a@m":"nope","b@m":[{"installPath":5},{}]}}""")] + [InlineData("""{"plugins":{"a@m":[{"installPath":"C:\\no\\such\\folder"}]}}""")] + public void List_survives_a_missing_or_garbled_installed_plugins_file(string? json) + { + var account = _home.AddAccount("x"); + FakeHome.WriteFile(Path.Combine(account.ConfigRoot, "settings.json"), """{"enabledPlugins":{"a@m":true,"b@m":true}}"""); + if (json is not null) FakeHome.WriteFile(Path.Combine(account.ConfigRoot, "plugins", "installed_plugins.json"), json); + Assert.Equal(SlashCommands.List(_home.AddAccount("y"), null).Count, SlashCommands.List(account, null).Count); + } } From 918e563b2e14367f884a9dbb61b83da16a605b33 Mon Sep 17 00:00:00 2001 From: PieMH Date: Tue, 20 Oct 2026 19:39:02 +0200 Subject: [PATCH 03/12] Compose box: the / list also opens for a / word after a space or line break; text starting with a known command shows in blue --- src/CCWrapper/Theme.xaml | 2 + src/CCWrapper/Views/ComposeBox.xaml | 2 +- src/CCWrapper/Views/ComposeBox.xaml.cs | 64 ++++++++++++++++--- tests/CCWrapper.Tests/Input/SlashWordTests.cs | 44 +++++++++++++ 4 files changed, 101 insertions(+), 11 deletions(-) create mode 100644 tests/CCWrapper.Tests/Input/SlashWordTests.cs diff --git a/src/CCWrapper/Theme.xaml b/src/CCWrapper/Theme.xaml index 11d98b2..32e5d44 100644 --- a/src/CCWrapper/Theme.xaml +++ b/src/CCWrapper/Theme.xaml @@ -18,6 +18,8 @@ + + diff --git a/src/CCWrapper/Views/ComposeBox.xaml b/src/CCWrapper/Views/ComposeBox.xaml index 096b182..f2145cb 100644 --- a/src/CCWrapper/Views/ComposeBox.xaml +++ b/src/CCWrapper/Views/ComposeBox.xaml @@ -61,7 +61,7 @@ - + diff --git a/src/CCWrapper/Views/ComposeBox.xaml.cs b/src/CCWrapper/Views/ComposeBox.xaml.cs index b8f9ce7..6c55a19 100644 --- a/src/CCWrapper/Views/ComposeBox.xaml.cs +++ b/src/CCWrapper/Views/ComposeBox.xaml.cs @@ -62,6 +62,7 @@ void OnTextChanged(object sender, RoutedEventArgs e) _forced = false; UpdateHint(); UpdateSlash(); + UpdateColor(); } void OnCaretMoved(object sender, RoutedEventArgs e) => UpdateSlash(); @@ -76,7 +77,7 @@ void OnLostFocus(object sender, RoutedEventArgs e) (string, string?)? CommandsKey => _session is { } s ? (s.Account.Letter, s.Cwd) : null; - // Open while the text starts with "/" and the caret is in that first word (or forced from the + menu): filtered by what follows the "/". + // Open while the caret is in a word that starts with "/" (at the text start or after whitespace; not a path) or forced from the + menu: filtered by what follows the "/". void UpdateSlash() { if (_quiet) return; @@ -84,11 +85,7 @@ void UpdateSlash() var text = Input.Text; string? query = null; if (_forced) query = ""; - else if (text.StartsWith('/') && Input.IsKeyboardFocused) - { - var end = WordEnd(text); - if (Input.CaretIndex >= 1 && Input.CaretIndex <= end) query = text[1..end]; - } + else if (Input.IsKeyboardFocused && SlashWord.At(text, Input.CaretIndex) is var (start, end)) query = text[(start + 1)..end]; if (query is null) { CloseSlash(); return; } if (!_fresh && !_loading) _ = LoadCommandsAsync(key, _session!); var rows = SlashCommands.Filter(_commands.GetValueOrDefault(key) ?? [], query); @@ -111,9 +108,23 @@ async Task LoadCommandsAsync((string, string?) key, LiveSession session) finally { _loading = false; } _query = null; // the list may have grown under the same query UpdateSlash(); + UpdateColor(); } - static int WordEnd(string text) => text.AsSpan().IndexOfAny(" \t\r\n") is >= 0 and var at ? at : text.Length; + // shortcut: the whole text takes the color while it starts with a known command (a TextBox cannot color a range, and an overlay + // would lose IME composition and drift from the caret); upgrade if tokens inside the text must be told apart. + void UpdateColor() + { + var text = Input.Text; + if (CommandsKey is not { } key) { Input.ClearValue(ForegroundProperty); return; } + if (!_commands.TryGetValue(key, out var list)) + { + if (text.StartsWith('/') && !_loading) _ = LoadCommandsAsync(key, _session!); // a recalled "/command" never opens the list + list = []; + } + if (SlashWord.StartsWithCommand(text, list)) Input.Foreground = (Brush)FindResource("SlashText"); + else Input.ClearValue(ForegroundProperty); + } void CloseSlash() { @@ -137,8 +148,9 @@ void CompleteSlash(SlashCommand command) else { var text = Input.Text; - var end = WordEnd(text); - SetText(insert + text[end..].TrimStart(' ', '\t'), insert.Length); + if (SlashWord.At(text, Input.CaretIndex) is var (start, end)) + SetText(text[..start] + insert + text[end..].TrimStart(' ', '\t'), start + insert.Length); + else Insert(insert); } _quiet = false; CloseSlash(); @@ -216,6 +228,7 @@ void Refresh(LiveSession? session) CloseSlash(); } _session = session; + UpdateColor(); var blocked = TuiInput.BlockedReason(session); Notice.Text = blocked ?? _refusal; Notice.Visibility = Notice.Text is { Length: > 0 } ? Visibility.Visible : Visibility.Collapsed; @@ -260,7 +273,9 @@ void OnKeyDown(object sender, KeyEventArgs e) else if (e.Key is Key.Up or Key.Down) MoveSlash(e.Key == Key.Up ? -1 : 1); else if (SlashList.SelectedItem is SlashCommand c) { - var send = e.Key == Key.Enter && !_forced; // Enter runs the chosen command, as in the TUI; Tab, or a command put into text, only completes + // Enter runs the chosen command, as in the TUI, when it is the start of the text; Tab, a command put into text and a word + // in the middle of a prompt (sending would submit it half written) only complete + var send = e.Key == Key.Enter && !_forced && SlashWord.At(Input.Text, Input.CaretIndex) is { Start: 0 }; CompleteSlash(c); if (send && SendButton.IsEnabled) _ = SendAsync(); } @@ -341,6 +356,35 @@ void OnDrop(object sender, DragEventArgs e) } } +/// Where the compose box's "/" word is. +public static class SlashWord +{ + static bool Space(char c) => c is ' ' or '\t' or '\r' or '\n'; + + /// The word the caret is in when it starts with "/" at the text start or right after whitespace (so not "a/b" or a URL) + /// and the caret is after that "/" and not past the word's end: its start (the "/") and end (exclusive). Else null. + public static (int Start, int End)? At(string text, int caret) + { + caret = Math.Clamp(caret, 0, text.Length); + var start = caret; + while (start > 0 && !Space(text[start - 1])) start--; + if (start >= text.Length || text[start] != '/' || caret <= start) return null; + var end = start; + while (end < text.Length && !Space(text[end])) end++; + return (start, end); + } + + /// True when the text starts with "/name", the name being one of (case-insensitive). + public static bool StartsWithCommand(string text, IEnumerable commands) + { + if (!text.StartsWith('/')) return false; + var end = 1; + while (end < text.Length && !Space(text[end])) end++; + var name = text[1..end]; + return name.Length > 0 && commands.Any(c => c.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + } +} + /// What may go into the TUI input, and how it goes in. Shared by the compose box, the bottom bar and the /// reload button of Plugins & connectors. public static class TuiInput diff --git a/tests/CCWrapper.Tests/Input/SlashWordTests.cs b/tests/CCWrapper.Tests/Input/SlashWordTests.cs new file mode 100644 index 0000000..e99c024 --- /dev/null +++ b/tests/CCWrapper.Tests/Input/SlashWordTests.cs @@ -0,0 +1,44 @@ +using CCWrapper.Data; +using CCWrapper.Views; + +namespace CCWrapper.Tests.Input; + +public sealed class SlashWordTests +{ + [Theory] + [InlineData("/com", 4, 0, 4)] // at the start, caret at the end + [InlineData("/com", 1, 0, 4)] // right after the "/" + [InlineData("/com rest", 2, 0, 4)] + [InlineData("run /com now", 8, 4, 8)] // after a space + [InlineData("a\n/co", 5, 2, 5)] // after a newline + [InlineData("a\t/co", 4, 2, 5)] // after a tab + public void At_finds_the_word_the_caret_is_in(string text, int caret, int start, int end) + { + Assert.Equal((start, end), SlashWord.At(text, caret)); + } + + [Theory] + [InlineData("a/b", 3)] // inside a path + [InlineData("see C:/dir/file", 10)] + [InlineData("https://x.y/z", 13)] + [InlineData("/com ", 5)] // caret after the word, past the space + [InlineData("run /com now", 12)] + [InlineData("/com", 0)] // before the "/" + [InlineData("run /com", 4)] + [InlineData("plain text", 5)] + [InlineData("", 0)] + public void Not_a_word(string text, int caret) => Assert.Null(SlashWord.At(text, caret)); + + [Fact] + public void StartsWithCommand_wants_the_first_word_to_be_a_known_name() + { + SlashCommand[] known = [new("compact", "", "t"), new("my:cmd", "", "t")]; + Assert.True(SlashWord.StartsWithCommand("/compact", known)); + Assert.True(SlashWord.StartsWithCommand("/COMPACT now\nmore", known)); + Assert.True(SlashWord.StartsWithCommand("/my:cmd arg", known)); + Assert.False(SlashWord.StartsWithCommand("/compa", known)); + Assert.False(SlashWord.StartsWithCommand("/", known)); + Assert.False(SlashWord.StartsWithCommand(" /compact", known)); + Assert.False(SlashWord.StartsWithCommand("run /compact", known)); + } +} From 87e2ec9e51a22bec577f11681bf3b7a8b8d01524 Mon Sep 17 00:00:00 2001 From: PieMH Date: Tue, 20 Oct 2026 19:42:19 +0200 Subject: [PATCH 04/12] Compose box: each session keeps its own draft; drafts are saved per account on exit and come back when the session is reopened --- src/CCWrapper/App.xaml.cs | 1 + src/CCWrapper/Core/AppUpdate.cs | 4 +- src/CCWrapper/Core/DraftStore.cs | 81 ++++++++++++++++++ src/CCWrapper/Views/ComposeBox.xaml.cs | 15 +++- tests/CCWrapper.Tests/DraftStoreTests.cs | 101 +++++++++++++++++++++++ 5 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 src/CCWrapper/Core/DraftStore.cs create mode 100644 tests/CCWrapper.Tests/DraftStoreTests.cs diff --git a/src/CCWrapper/App.xaml.cs b/src/CCWrapper/App.xaml.cs index c91cf05..6fb04c4 100644 --- a/src/CCWrapper/App.xaml.cs +++ b/src/CCWrapper/App.xaml.cs @@ -35,6 +35,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..34ec70e --- /dev/null +++ b/src/CCWrapper/Core/DraftStore.cs @@ -0,0 +1,81 @@ +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. 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 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(string path) +{ + public const int Cap = 100; + const string TabPrefix = "tab:"; + static readonly Dictionary Stores = []; // UI thread only + + readonly OrderedDictionary _drafts = Read(path); // oldest edit first + + 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; 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); + _drafts.Remove(tab); + var key = s.SessionId ?? tab; + _drafts.Remove(key); + if (text.Length > 0) _drafts[key] = new(text, caret); + } + + /// 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).TakeLast(Cap) + .Select(d => new Row(d.Key, d.Value.Text, d.Value.Caret)).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); + + static OrderedDictionary Read(string path) + { + var drafts = new OrderedDictionary(); + try + { + if (File.Exists(path) && JsonSerializer.Deserialize>(File.ReadAllText(path)) is { } rows) + foreach (var r in rows) + if (r is { Key.Length: > 0, Text.Length: > 0 }) drafts[r.Key] = new(r.Text, Math.Max(0, r.Caret)); + } + catch (Exception) { } // broken file: no drafts + return drafts; + } +} diff --git a/src/CCWrapper/Views/ComposeBox.xaml.cs b/src/CCWrapper/Views/ComposeBox.xaml.cs index 6c55a19..3296962 100644 --- a/src/CCWrapper/Views/ComposeBox.xaml.cs +++ b/src/CCWrapper/Views/ComposeBox.xaml.cs @@ -60,11 +60,18 @@ async void OnPoll(object? sender, EventArgs e) void OnTextChanged(object sender, RoutedEventArgs e) { _forced = false; + Stash(_session); UpdateHint(); UpdateSlash(); UpdateColor(); } + // The box's text is the active session's draft, kept in the store as it changes (written to disk only on exit). + void Stash(LiveSession? s) + { + if (s is not null) DraftStore.For(s.Account).Set(s, Input.Text, Input.CaretIndex); + } + void OnCaretMoved(object sender, RoutedEventArgs e) => UpdateSlash(); void OnLostFocus(object sender, RoutedEventArgs e) @@ -222,12 +229,17 @@ void Refresh(LiveSession? session) { if (session != _session) { + Stash(_session); // with the caret _refusal = null; _suggestion = null; UpdateHint(); CloseSlash(); + _session = session; + var draft = session is null ? null : DraftStore.For(session.Account).Get(session); + SetText(draft?.Text ?? "", Math.Min(draft?.Caret ?? 0, draft?.Text.Length ?? 0)); // _quiet: a restored "/cmd" does not open the list } _session = session; + Stash(session); // a tab that just got its session id moves its draft to it UpdateColor(); var blocked = TuiInput.BlockedReason(session); Notice.Text = blocked ?? _refusal; @@ -251,7 +263,8 @@ async Task SendAsync() { if (await TuiInput.SubmitAsync(session, text) is not null) return; // keep the text: not submitted, the notice says why HistoryOf(session).Add(text); - Input.Clear(); + if (session == _session) Input.Clear(); + else DraftStore.For(session.Account).Set(session, "", 0); // the box shows another session's draft by now _suggestion = null; // it was sent, or Claude Code dropped it when the text went in UpdateHint(); } diff --git a/tests/CCWrapper.Tests/DraftStoreTests.cs b/tests/CCWrapper.Tests/DraftStoreTests.cs new file mode 100644 index 0000000..81330bb --- /dev/null +++ b/tests/CCWrapper.Tests/DraftStoreTests.cs @@ -0,0 +1,101 @@ +using System.IO; +using CCWrapper.Core; + +namespace CCWrapper.Tests; + +public sealed class DraftStoreTests : IDisposable +{ + readonly string _dir = Directory.CreateTempSubdirectory("ccw-drafts-").FullName; + string PathOf(string name) => System.IO.Path.Combine(_dir, name); + static readonly Account Acc = new("c", @"C:\none\.claude-c", @"C:\none\.claude-c\.claude.json", "t@example.com"); + + public void Dispose() => Directory.Delete(_dir, recursive: true); + + static LiveSession Tab(string? id = null) => new(Acc) { SessionId = id }; + + [Fact] + public void Round_trips_through_the_file_only_on_save() + { + var path = PathOf("drafts-c.json"); + var s = Tab("abc"); + var store = new DraftStore(path); + store.Set(s, "hello\nworld", 4); + Assert.False(File.Exists(path)); // memory until Save + + store.Save(); + + Assert.Equal(new Draft("hello\nworld", 4), new DraftStore(path).Get(s)); + Assert.Null(new DraftStore(path).Get(Tab("other"))); + } + + [Fact] + public void Each_account_file_is_its_own() + { + var a = new DraftStore(PathOf("drafts-a.json")); var c = new DraftStore(PathOf("drafts-c.json")); + a.Set(Tab("x"), "from a", 0); + a.Save(); c.Save(); + Assert.Null(new DraftStore(PathOf("drafts-c.json")).Get(Tab("x"))); + Assert.NotNull(new DraftStore(PathOf("drafts-a.json")).Get(Tab("x"))); + } + + [Fact] + public void Missing_or_broken_file_is_empty_and_replaced_on_save() + { + var path = PathOf("drafts-c.json"); + Assert.Null(new DraftStore(path).Get(Tab("x"))); + foreach (var junk in new[] { "{ not json", "{}", "[null,{\"Key\":\"x\"},{\"Key\":\"x\",\"Text\":\"\"}]" }) + { + File.WriteAllText(path, junk); + var store = new DraftStore(path); + Assert.Null(store.Get(Tab("x"))); + store.Set(Tab("y"), "ok", 2); + store.Save(); + Assert.Equal("ok", new DraftStore(path).Get(Tab("y"))!.Text); + } + } + + [Fact] + public void Empty_text_drops_the_draft() + { + var s = Tab("x"); + var store = new DraftStore(PathOf("d.json")); + store.Set(s, "text", 1); + store.Set(s, "", 0); + Assert.Null(store.Get(s)); + } + + [Fact] + public void A_tab_draft_moves_to_the_session_id_and_is_not_saved_without_one() + { + var path = PathOf("d.json"); + var store = new DraftStore(path); + var s = Tab(); + store.Set(s, "early", 5); + s.SessionId = "late"; + Assert.Equal("early", store.Get(s)!.Text); + + var noId = Tab(); + store.Set(noId, "lost", 4); + store.Save(); + var back = new DraftStore(path); + Assert.Equal("early", back.Get(s)!.Text); + Assert.Null(back.Get(noId)); + } + + [Fact] + public void Save_keeps_the_newest_Cap_drafts() + { + var path = PathOf("d.json"); + var store = new DraftStore(path); + for (var i = 0; i < DraftStore.Cap + 5; i++) store.Set(Tab($"s{i}"), "t", 0); + store.Set(Tab("s0"), "touched", 0); // the oldest, edited again: now the newest + store.Save(); + + var back = new DraftStore(path); + Assert.Null(back.Get(Tab("s1"))); + Assert.Null(back.Get(Tab("s5"))); + Assert.NotNull(back.Get(Tab("s6"))); + Assert.NotNull(back.Get(Tab("s0"))); + Assert.NotNull(back.Get(Tab($"s{DraftStore.Cap + 4}"))); + } +} From f160e3e4b120d60668dde93b253f885e40371245 Mon Sep 17 00:00:00 2001 From: PieMH Date: Tue, 20 Oct 2026 19:48:06 +0200 Subject: [PATCH 05/12] Bottom bar: click the usage meters for a detail popup: both windows' bars and resets, tokens by model and by hour of day from the transcripts --- src/CCWrapper/Data/UsageStats.cs | 117 ++++++++++++++ src/CCWrapper/Theme.xaml | 18 +++ src/CCWrapper/Views/BottomBar.xaml | 9 +- src/CCWrapper/Views/BottomBar.xaml.cs | 143 +++++++++++++++++- tests/CCWrapper.Tests/Data/UsageStatsTests.cs | 129 ++++++++++++++++ 5 files changed, 410 insertions(+), 6 deletions(-) create mode 100644 src/CCWrapper/Data/UsageStats.cs create mode 100644 tests/CCWrapper.Tests/Data/UsageStatsTests.cs 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/Theme.xaml b/src/CCWrapper/Theme.xaml index 32e5d44..1182edd 100644 --- a/src/CCWrapper/Theme.xaml +++ b/src/CCWrapper/Theme.xaml @@ -514,4 +514,22 @@ + + + + diff --git a/src/CCWrapper/Views/BottomBar.xaml b/src/CCWrapper/Views/BottomBar.xaml index 08dc541..f320172 100644 --- a/src/CCWrapper/Views/BottomBar.xaml +++ b/src/CCWrapper/Views/BottomBar.xaml @@ -41,7 +41,8 @@ - + @@ -49,6 +50,12 @@ + + + + + + diff --git a/src/CCWrapper/Views/BottomBar.xaml.cs b/src/CCWrapper/Views/BottomBar.xaml.cs index 75b18e7..84ab518 100644 --- a/src/CCWrapper/Views/BottomBar.xaml.cs +++ b/src/CCWrapper/Views/BottomBar.xaml.cs @@ -109,26 +109,20 @@ void Render() // Built when it opens: the "in 2h 10m" of the reset times is relative to now, not to the last render. void OnUsageTip(object sender, ToolTipEventArgs e) => UsageGroup.ToolTip = _usageTip(); - // StaysOpen=false closes the popup on the mouse-down of a click on the group itself, so a click right after a close must not reopen it. - void OnUsageClick(object sender, MouseButtonEventArgs e) => OpenUsage(); - - void OnUsageGroupKey(object sender, KeyEventArgs e) - { - if (e.Key is not (Key.Enter or Key.Space)) return; - OpenUsage(); - e.Handled = true; - } + // StaysOpen=false closes the popup on the mouse-down of a click on the button itself, so a click right after a close must not reopen it. + void OnUsageClick(object sender, RoutedEventArgs e) => OpenUsage(); void OpenUsage() { if (UsagePopup.IsOpen || DateTime.UtcNow - _usageClosedAt < TimeSpan.FromMilliseconds(250)) return; _usageFocus = Keyboard.FocusedElement; + Keyboard.Focus(UsageGroup); // a no-op for a click (the Button focused itself), but it also moves Win32 focus off the WebView2 for Enter and Space UsagePopup.HorizontalOffset = UsageGroup.ActualWidth - UsagePanel.Width; // right edges line up; WPF keeps it on screen var account = AppState.Current?.Account; var now = _usageNow; ShowUsage(now, null, loading: account is not null); UsagePopup.IsOpen = true; - // Once the popup's window exists, focus goes into it; before that the terminal may still hold it. + // The popup window is WS_EX_NOACTIVATE, so Win32 focus stays in the main window, on the button: the WebView2 lost it when the button took focus. WPF focus moves into the popup once it exists. Dispatcher.BeginInvoke(DispatcherPriority.Loaded, () => { if (UsagePopup.IsOpen) Keyboard.Focus(UsagePanel); }); if (account is null) return; // The transcripts are read off the UI thread; reopening within a minute gets the cached result at once. @@ -140,8 +134,8 @@ void OpenUsage() void OnUsageClosed(object? sender, EventArgs e) => _usageClosedAt = DateTime.UtcNow; - // Esc is taken here, in the tunnelling phase, while the popup's own window holds the keyboard focus: key messages go to the - // focused window only, so the terminal's WebView2 (and the TUI behind it, where Esc interrupts a running turn) never sees them. + // Esc is taken here, in the tunnelling phase. Win32 focus is on the main window (the button took it from the WebView2), so the key + // message reaches WPF and never the terminal's WebView2 (and the TUI behind it, where Esc interrupts a running turn). void OnUsageKey(object sender, KeyEventArgs e) { if (e.Key != Key.Escape) return; @@ -267,7 +261,7 @@ void OnUpdate(object sender, RoutedEventArgs e) var others = AppUpdate.OtherInstances(); var text = $"Restart now to install {AppUpdate.ReadyVersion}?"; if (busy > 0) text += $"\n\nThis ends {busy} busy or waiting session(s); they stay resumable."; - if (others > 0) text += $"\n\nThe other CCWrapper window(s) ({others}) close too, and their sessions end the same way. Their unsent drafts written after the update became ready are lost."; + if (others > 0) text += $"\n\nThe other CCWrapper window(s) ({others}) close too, and their sessions end the same way."; if (Dialog.Show(Window.GetWindow(this), text, "CCWrapper", MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes) AppUpdate.RestartNow(AppState.Current?.Account.Letter); } diff --git a/src/CCWrapper/Views/ComposeBox.xaml.cs b/src/CCWrapper/Views/ComposeBox.xaml.cs index 3296962..c1264e7 100644 --- a/src/CCWrapper/Views/ComposeBox.xaml.cs +++ b/src/CCWrapper/Views/ComposeBox.xaml.cs @@ -494,7 +494,7 @@ public static void SetSwitching(LiveSession s, bool on) /// The pastes goes in as: one, but a slash command as several of at most /// characters and one line break each (at its start). Claude Code folds a paste over 800 - /// characters or 3 lines into "[Pasted text #1]", and a folded slash command is sent as a plain prompt. + /// characters or of 4 lines or more into "[Pasted text #1]", and a folded slash command is sent as a plain prompt. /// shortcut: a terminal under about 12 rows may fold even a one-break paste; send breaks some other way if that shows up. internal static IEnumerable Pastes(string text) { diff --git a/tests/CCWrapper.Tests/DraftStoreTests.cs b/tests/CCWrapper.Tests/DraftStoreTests.cs index 00dae9b..c79dcd2 100644 --- a/tests/CCWrapper.Tests/DraftStoreTests.cs +++ b/tests/CCWrapper.Tests/DraftStoreTests.cs @@ -114,4 +114,20 @@ public void Save_keeps_the_newest_Cap_drafts() Assert.NotNull(back.Get(Tab("s0"))); Assert.NotNull(back.Get(Tab($"s{DraftStore.Cap + 4}"))); } + + [Fact] + public void A_changed_session_id_does_not_delete_the_draft_another_tab_wrote_under_the_old_one() + { + var store = new DraftStore(PathOf("d.json")); + var a = Tab("X"); + store.Set(a, "from A", 0); + a.SessionId = "Y"; // /clear in the background tab + var b = Tab("X"); // resumed X in another tab + store.Set(b, "from B", 0); + + store.Set(a, "A again", 0); + + Assert.Equal("from B", store.Get(b)!.Text); + Assert.Equal("A again", store.Get(a)!.Text); + } } From 1f5e7b507baa03903f019015a3071d3e17d04349 Mon Sep 17 00:00:00 2001 From: PieMH Date: Tue, 20 Oct 2026 20:23:08 +0200 Subject: [PATCH 09/12] QA fixes: Esc gives focus back to where it was, the usage button opens on press, an equal draft another tab stored is kept, honest wording on drafts lost to another window's update restart --- CLAUDE.md | 2 +- PRIVACY.md | 2 +- README.md | 6 +++--- src/CCWrapper/Core/DraftStore.cs | 2 +- src/CCWrapper/Views/BottomBar.xaml | 4 ++-- src/CCWrapper/Views/BottomBar.xaml.cs | 8 +++++--- tests/CCWrapper.Tests/DraftStoreTests.cs | 15 +++++++++++++++ 7 files changed, 28 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ce9e009..a88d07d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ 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; `DraftStore` (compose-box drafts, `drafts-.json`, saved on exit, before an update restart and when the app is deactivated with an update ready); `AppUpdate` (auto-update), `AppPrefs` (`updateCheck`, `browserTool`) and `CompactPrefs` (`app.json`: each rewrites only its own key) | +| `src/CCWrapper/Core` | Accounts and paths, `AppState`, the `LiveSession` contract the views bind to; `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`) | diff --git a/PRIVACY.md b/PRIVACY.md index ffa46dd..594f01c 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -78,7 +78,7 @@ If the runtime is missing on your PC, the installer (`Setup.exe`) downloads it f | `%LOCALAPPDATA%\CCWrapper\WebView2` | Browser profiles of the terminal and of the Browser panel, one per account: cookies, cache, site data. | | `%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\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 an update is ready to install (so a restart started from another window cannot lose them), and read when a session is reopened. Not encrypted. Not removed when you delete a session. If two windows of one account are open, the one that exits last wins. | +| `%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. Not removed when you delete a 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. diff --git a/README.md b/README.md index 982db7f..69b2843 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-489%20passing-brightgreen) +![Tests](https://img.shields.io/badge/tests-490%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. @@ -84,7 +84,7 @@ It stays light: a native WPF app (no Electron), Windows' own icon font, a dark s - 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 `/` (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 with an update ready to install, and come back when the session is reopened. +- 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. 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. @@ -223,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 489 tests on synthetic fixtures. +- xUnit, with 490 tests on synthetic fixtures. ## Development diff --git a/src/CCWrapper/Core/DraftStore.cs b/src/CCWrapper/Core/DraftStore.cs index 6a7fb9a..c9ddfeb 100644 --- a/src/CCWrapper/Core/DraftStore.cs +++ b/src/CCWrapper/Core/DraftStore.cs @@ -46,7 +46,7 @@ public void Set(LiveSession s, string text, int caret) _drafts.Remove(tab); var key = s.SessionId ?? 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) && cur == old.Draft) _drafts.Remove(old.Key); + if (_lastKey.TryGetValue(s.TabId, out var old) && old.Key != key && _drafts.TryGetValue(old.Key, out var cur) && ReferenceEquals(cur, old.Draft)) _drafts.Remove(old.Key); _drafts.Remove(key); Draft? draft = text.Length > 0 ? new(text, caret) : null; if (draft is not null) _drafts[key] = draft; diff --git a/src/CCWrapper/Views/BottomBar.xaml b/src/CCWrapper/Views/BottomBar.xaml index 3795599..19239f1 100644 --- a/src/CCWrapper/Views/BottomBar.xaml +++ b/src/CCWrapper/Views/BottomBar.xaml @@ -41,9 +41,9 @@ - +