From c5632be93ee22e0ac58b15230cb02197db63875e Mon Sep 17 00:00:00 2001 From: PieMH Date: Wed, 28 Oct 2026 15:06:19 +0100 Subject: [PATCH 01/17] T3: Agents tab as a background-tasks list with search and an inline log --- src/CCWrapper/Data/SubagentReader.cs | 9 +- src/CCWrapper/Data/TranscriptReader.cs | 3 + src/CCWrapper/Views/AgentsPanel.xaml | 99 ++++++++++------ src/CCWrapper/Views/AgentsPanel.xaml.cs | 109 ++++++++++++++++-- .../Detail/SubagentReaderTests.cs | 2 + tests/CCWrapper.Tests/Views/AgentRowTests.cs | 56 +++++++++ 6 files changed, 233 insertions(+), 45 deletions(-) create mode 100644 tests/CCWrapper.Tests/Views/AgentRowTests.cs diff --git a/src/CCWrapper/Data/SubagentReader.cs b/src/CCWrapper/Data/SubagentReader.cs index 62a844a..d63f751 100644 --- a/src/CCWrapper/Data/SubagentReader.cs +++ b/src/CCWrapper/Data/SubagentReader.cs @@ -7,9 +7,14 @@ namespace CCWrapper.Data; /// One subagent of a session. Meta fields come from agent-*.meta.json, the numbers from its transcript. /// Last turn's input + cache creation + cache read tokens. +/// Timestamp of its first transcript record; with it gives the duration. public sealed record SubagentInfo(string AgentId, string? AgentType, string? Description, string? ToolUseId, string? RequestShape, string? Model, string? Effort, long? ContextTokens, long OutputTokens, int ToolUseCount, DateTimeOffset? LastActivity, - string TranscriptPath, bool IsRunning); + string TranscriptPath, bool IsRunning, DateTimeOffset? FirstActivity = null) +{ + /// The "tokens" of the Agents list: the last turn's context () plus all output tokens. Null until a turn reports usage. + public long? Tokens => ContextTokens + OutputTokens; +} /// The subagents of one session, from <session dir>\subagents. Call on a timer. public sealed class SubagentReader(string transcriptPath) @@ -75,6 +80,6 @@ static SubagentInfo Describe(string metaPath, JsonElement meta, TranscriptReader var running = age < RecentWrite || (r.LastStopReason != "end_turn" && age < StaleAfter); return new SubagentInfo(id, Str(meta, "agentType"), Str(meta, "description"), Str(meta, "toolUseId"), Str(meta, "requestShape"), r.Model ?? Str(meta, "model"), r.Effort ?? Str(meta, "effort"), r.ContextTokens, r.OutputTokens, r.ToolUseCount, - r.LastActivity, r.Path, running); + r.LastActivity, r.Path, running, r.FirstActivity); } } diff --git a/src/CCWrapper/Data/TranscriptReader.cs b/src/CCWrapper/Data/TranscriptReader.cs index 42cb4dc..49f7a1d 100644 --- a/src/CCWrapper/Data/TranscriptReader.cs +++ b/src/CCWrapper/Data/TranscriptReader.cs @@ -37,6 +37,8 @@ public sealed class TranscriptReader(string path) /// stop_reason of the last assistant record; null while streaming or before any. public string? LastStopReason { get; private set; } public int ToolUseCount { get; private set; } + /// Timestamp of the first record that has one. + public DateTimeOffset? FirstActivity { get; private set; } /// Timestamp of the last record that has one. public DateTimeOffset? LastActivity { get; private set; } public IReadOnlyList Messages => _messages; @@ -64,6 +66,7 @@ public bool Poll() bool Apply(JsonElement rec) { if (TimeOf(rec) is { } when) LastActivity = when; + FirstActivity ??= LastActivity; switch (Str(rec, "type")) { diff --git a/src/CCWrapper/Views/AgentsPanel.xaml b/src/CCWrapper/Views/AgentsPanel.xaml index dbfc517..9f7879a 100644 --- a/src/CCWrapper/Views/AgentsPanel.xaml +++ b/src/CCWrapper/Views/AgentsPanel.xaml @@ -1,39 +1,72 @@ + xmlns:local="clr-namespace:CCWrapper.Views" + PreviewKeyDown="OnPreviewKeyDown" PreviewMouseDown="OnPreviewMouseDown"> - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/CCWrapper/Views/AgentsPanel.xaml.cs b/src/CCWrapper/Views/AgentsPanel.xaml.cs index d23cf8d..6ca87fa 100644 --- a/src/CCWrapper/Views/AgentsPanel.xaml.cs +++ b/src/CCWrapper/Views/AgentsPanel.xaml.cs @@ -1,10 +1,11 @@ using System.Collections.ObjectModel; +using System.ComponentModel; using System.Globalization; using System.Text.RegularExpressions; using System.Windows; using System.Windows.Controls; +using System.Windows.Data; using System.Windows.Input; -using CCWrapper.Core; using CCWrapper.Data; namespace CCWrapper.Views; @@ -13,45 +14,119 @@ public partial class AgentsPanel : UserControl { readonly SessionFollower> _follower; readonly ObservableCollection _rows = []; + readonly ICollectionView _view; + SubagentInfo? _open; // the agent whose log is showing + string _noAgents = ""; public AgentsPanel() { InitializeComponent(); List.ItemsSource = _rows; + _view = CollectionViewSource.GetDefaultView(_rows); + _view.Filter = o => AgentRow.Matches(((AgentRow)o).Info, SearchBox.Text); + // leaving the tab unloads the log and stops its polling: start a fresh one on return + Loaded += (_, _) => { if (_open is { } o) Detail.Content = new TranscriptView(o.TranscriptPath); }; _follower = new(this, s => new SubagentReader(s.TranscriptPath!), r => { r.Poll(); return r.Agents; }, Show); } - /// Null = no session. Updates rows in place so the scroll position survives the polls; the age text moves with the clock. + /// Null = no session. Updates rows in place so the scroll position survives the polls; the duration moves with the clock. void Show(IReadOnlyList? agents) { - Empty.Text = agents is null ? "No active session" : "No subagents yet"; + if (agents is null) Close(); // another session: its agents are not this one's + _noAgents = agents is null ? "No active session" : "No subagents yet"; var now = DateTimeOffset.Now; var rows = (agents ?? []).Select(a => AgentRow.From(a, now)).ToList(); - Empty.Visibility = rows.Count == 0 ? Visibility.Visible : Visibility.Collapsed; for (var i = 0; i < rows.Count; i++) { if (i >= _rows.Count) _rows.Add(rows[i]); else if (_rows[i] != rows[i]) _rows[i] = rows[i]; } while (_rows.Count > rows.Count) _rows.RemoveAt(_rows.Count - 1); + UpdateEmpty(); } - void OnDoubleClick(object sender, MouseButtonEventArgs e) + void UpdateEmpty() { - if (sender is ListBoxItem { DataContext: AgentRow row }) - AppState.Current.Commands.ShowTranscript(row.Info.TranscriptPath, row.Title); + Empty.Text = _rows.Count == 0 ? _noAgents : "No matching agents"; + Empty.Visibility = _view.IsEmpty ? Visibility.Visible : Visibility.Collapsed; + } + + void OnSearch(object sender, TextChangedEventArgs e) + { + _view.Refresh(); + UpdateEmpty(); + } + + void OnRowClick(object sender, MouseButtonEventArgs e) + { + if (sender is ListBoxItem { DataContext: AgentRow row }) Open(row); + } + + void OnRowKeyDown(object sender, KeyEventArgs e) + { + if (e.Key != Key.Enter || sender is not ListBoxItem { DataContext: AgentRow row }) return; + Open(row); + e.Handled = true; + } + + /// Swaps the list for the agent's log; the list stays in the tree, so its scroll position and search survive. + void Open(AgentRow row) + { + _open = row.Info; + DetailTitle.Text = row.Title; + Detail.Content = new TranscriptView(row.Info.TranscriptPath); + (ListPane.Visibility, DetailPane.Visibility) = (Visibility.Collapsed, Visibility.Visible); + BackButton.Focus(); // the collapsed row held the focus, and Esc needs it in this panel + } + + void Close() + { + if (_open is null) return; + _open = null; + Detail.Content = null; // unloading stops the log's polling + (ListPane.Visibility, DetailPane.Visibility) = (Visibility.Visible, Visibility.Collapsed); + List.Focus(); + } + + void OnBack(object sender, RoutedEventArgs e) => Close(); + + void OnPreviewKeyDown(object sender, KeyEventArgs e) + { + if (e.Key != Key.Escape || _open is null) return; + Close(); + e.Handled = true; + } + + void OnPreviewMouseDown(object sender, MouseButtonEventArgs e) + { + if (e.ChangedButton != MouseButton.XButton1 || _open is null) return; + Close(); + e.Handled = true; } } -/// One line group of the Agents list. Record equality lets skip rows that did not change. -public sealed record AgentRow(SubagentInfo Info, string Age) +/// One row of the Agents list. Record equality lets skip rows that did not change. +public sealed record AgentRow(SubagentInfo Info, string Age, string? Duration) { + static readonly Regex ModelId = new(@"^claude-(opus|sonnet|haiku)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$"); + public bool IsRunning => Info.IsRunning; public string Title => Info.Description ?? Info.AgentType ?? Info.AgentId; + /// "Opus 5.5 · 114.1k tokens · high"; what is unknown is left out. + public string Subtitle => string.Join(" · ", new[] { ModelName(Info.Model), Info.Tokens is { } t ? Tokens(t) + " tokens" : null, Info.Effort }.Where(s => !string.IsNullOrEmpty(s))); + /// The hover text: the agent type and the numbers behind the subtitle. + public string Tooltip => Detail + "\n" + Stats; public string Detail => string.Join(" · ", new[] { Info.AgentType, ShortModel(Info.Model), Info.Effort }.Where(s => !string.IsNullOrEmpty(s))); public string Stats => $"ctx {Tokens(Info.ContextTokens)} · out {Tokens(Info.OutputTokens)} · {Info.ToolUseCount} tools · {Age}"; - public static AgentRow From(SubagentInfo info, DateTimeOffset now) => new(info, info.LastActivity is { } t ? FormatAge(now - t) : "—"); + public static AgentRow From(SubagentInfo info, DateTimeOffset now) => + new(info, info.LastActivity is { } t ? FormatAge(now - t) : "—", + info.FirstActivity is { } first && (info.IsRunning ? now : info.LastActivity) is { } end ? FormatDuration(end - first) : null); + + /// Case-insensitive match of the search text in the description or agent type; empty text matches all. + public static bool Matches(SubagentInfo info, string query) => + query.Length == 0 || (info.Description?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false) + || (info.AgentType?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false); public static string Tokens(long? n) => n switch { @@ -69,6 +144,20 @@ public sealed record AgentRow(SubagentInfo Info, string Age) _ => $"{(int)age.TotalDays}d", }; + /// "42s", "3m 05s", "2h 07m". + public static string FormatDuration(TimeSpan d) => d.TotalSeconds switch + { + < 60 => $"{Math.Max(0, (int)d.TotalSeconds)}s", + < 3600 => $"{(int)d.TotalMinutes}m {d.Seconds:00}s", + _ => $"{(int)d.TotalHours}h {d.Minutes:00}m", + }; + /// "claude-opus-4-5-20251101" -> "opus-4-5". public static string? ShortModel(string? model) => model is null ? null : Regex.Replace(model, @"^claude-|-\d{8}$", ""); + + /// "claude-opus-5-5" -> "Opus 5.5", "claude-sonnet-5" -> "Sonnet 5"; an id of another shape is returned as is. + public static string? ModelName(string? model) => + model is not null && ModelId.Match(model) is { Success: true } m + ? $"{char.ToUpperInvariant(m.Groups[1].Value[0])}{m.Groups[1].Value[1..]} {m.Groups[2].Value}{(m.Groups[3].Success ? "." + m.Groups[3].Value : "")}" + : model; } diff --git a/tests/CCWrapper.Tests/Detail/SubagentReaderTests.cs b/tests/CCWrapper.Tests/Detail/SubagentReaderTests.cs index 70b3e48..cf3722b 100644 --- a/tests/CCWrapper.Tests/Detail/SubagentReaderTests.cs +++ b/tests/CCWrapper.Tests/Detail/SubagentReaderTests.cs @@ -44,6 +44,8 @@ public void Running_agent_is_listed_before_a_done_one_with_its_stats() Assert.Equal(5 + 8, run.OutputTokens); Assert.Equal(2, run.ToolUseCount); Assert.Equal(DateTimeOffset.Parse("2026-01-01T10:00:03Z"), run.LastActivity); + Assert.Equal(DateTimeOffset.Parse("2026-01-01T10:00:01Z"), run.FirstActivity); + Assert.Equal(203 + 13, run.Tokens); Assert.Equal(runPath, run.TranscriptPath); Assert.Equal("done", done.AgentId); diff --git a/tests/CCWrapper.Tests/Views/AgentRowTests.cs b/tests/CCWrapper.Tests/Views/AgentRowTests.cs new file mode 100644 index 0000000..b105d02 --- /dev/null +++ b/tests/CCWrapper.Tests/Views/AgentRowTests.cs @@ -0,0 +1,56 @@ +using CCWrapper.Data; +using CCWrapper.Views; + +namespace CCWrapper.Tests.Views; + +public sealed class AgentRowTests +{ + [Theory] + [InlineData("claude-opus-5-5", "Opus 5.5")] + [InlineData("claude-opus-4-5-20251101", "Opus 4.5")] + [InlineData("claude-sonnet-5", "Sonnet 5")] + [InlineData("claude-haiku-4-5-20251001", "Haiku 4.5")] + [InlineData("claude-opus-4-20250514", "Opus 4")] + [InlineData("gpt-5", "gpt-5")] + [InlineData("claude-opus-5-5[1m]", "claude-opus-5-5[1m]")] + public void Model_id_becomes_a_display_name(string id, string expected) => Assert.Equal(expected, AgentRow.ModelName(id)); + + [Fact] + public void Missing_model_stays_missing() => Assert.Null(AgentRow.ModelName(null)); + + [Fact] + public void Duration_is_first_to_last_activity_or_to_now_while_running() + { + var t0 = DateTimeOffset.Parse("2026-01-01T10:00:00Z"); + SubagentInfo Make(bool running, DateTimeOffset? first, DateTimeOffset? last) => + new("a", "Explore", "Find it", null, null, "claude-opus-5-5", "high", 114_000, 100, 3, last, "t.jsonl", running, first); + + Assert.Equal("2m 05s", AgentRow.From(Make(false, t0, t0.AddSeconds(125)), t0.AddHours(1)).Duration); + Assert.Equal("1h 01m", AgentRow.From(Make(true, t0, t0.AddSeconds(125)), t0.AddMinutes(61)).Duration); + Assert.Null(AgentRow.From(Make(false, null, t0), t0).Duration); + Assert.Equal(["0s", "42s", "3m 05s", "2h 07m"], new[] { -5, 42, 185, 7620 }.Select(s => AgentRow.FormatDuration(TimeSpan.FromSeconds(s)))); + } + + [Fact] + public void Subtitle_has_model_tokens_and_effort_and_skips_what_is_unknown() + { + var info = new SubagentInfo("a", "Explore", "Find it", null, null, "claude-opus-5-5", "high", 114_000, 100, 3, null, "t.jsonl", false); + Assert.Equal(114_100, info.Tokens); + Assert.Equal("Opus 5.5 · 114.1k tokens · high", AgentRow.From(info, DateTimeOffset.Now).Subtitle); + + var bare = info with { Model = null, Effort = null, ContextTokens = null }; + Assert.Null(bare.Tokens); + Assert.Equal("", AgentRow.From(bare, DateTimeOffset.Now).Subtitle); + } + + [Fact] + public void Search_matches_description_and_type_ignoring_case() + { + var info = new SubagentInfo("a", "general-purpose", "Scan the Repo", null, null, null, null, null, 0, 0, null, "t.jsonl", false); + Assert.True(AgentRow.Matches(info, "")); + Assert.True(AgentRow.Matches(info, "scan the repo")); + Assert.True(AgentRow.Matches(info, "PURPOSE")); + Assert.False(AgentRow.Matches(info, "zzz")); + Assert.False(AgentRow.Matches(info with { Description = null, AgentType = null }, "scan")); + } +} From 667320217e6570fd0a66d129ccd783bccb30b2c6 Mon Sep 17 00:00:00 2001 From: PieMH Date: Wed, 28 Oct 2026 15:06:53 +0100 Subject: [PATCH 02/17] T2: model, effort and mode choosers as usage-style popups, model versions, Ultracode switch --- src/CCWrapper/Theme.xaml | 7 + src/CCWrapper/Views/BottomBar.xaml | 15 +- src/CCWrapper/Views/BottomBar.xaml.cs | 161 +++++++++++++++--- tests/CCWrapper.Tests/Shell/BarChoiceTests.cs | 38 +++++ 4 files changed, 190 insertions(+), 31 deletions(-) create mode 100644 tests/CCWrapper.Tests/Shell/BarChoiceTests.cs diff --git a/src/CCWrapper/Theme.xaml b/src/CCWrapper/Theme.xaml index 1182edd..c91eaa8 100644 --- a/src/CCWrapper/Theme.xaml +++ b/src/CCWrapper/Theme.xaml @@ -532,4 +532,11 @@ + + diff --git a/src/CCWrapper/Views/BottomBar.xaml b/src/CCWrapper/Views/BottomBar.xaml index 944720b..fede86f 100644 --- a/src/CCWrapper/Views/BottomBar.xaml +++ b/src/CCWrapper/Views/BottomBar.xaml @@ -43,7 +43,7 @@ - - - + + + + + + diff --git a/src/CCWrapper/Views/AboutWindow.xaml.cs b/src/CCWrapper/Views/AboutWindow.xaml.cs index a562e5f..7f547dc 100644 --- a/src/CCWrapper/Views/AboutWindow.xaml.cs +++ b/src/CCWrapper/Views/AboutWindow.xaml.cs @@ -1,11 +1,15 @@ using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; using System.Windows; using System.Windows.Controls; using CCWrapper.Core; +using CCWrapper.Terminal; namespace CCWrapper.Views; -/// Version, the update switch (applies at once, saved in app.json) and the project's documents on GitHub. +/// Version, the update switch (applies at once, saved in app.json), the project's documents on GitHub and the bug +/// report button, which only opens the prefilled issue form in the browser. public partial class AboutWindow : Window { static readonly (string Label, string File)[] Documents = @@ -33,4 +37,37 @@ public AboutWindow() } void OnSwitch(object sender, RoutedEventArgs e) => AppUpdate.SetEnabled(UpdateSwitch.IsChecked == true); + + /// The GitHub issue form with the versions filled in. Nothing leaves the PC until the user submits it there; the + /// Claude Code field is left out when it is unknown. + public static string BugReportUrl(string appVersion, string windows, string? claudeVersion) + { + var url = $"{AppUpdate.Repo}/issues/new?template=bug_report.yml&app-version={Uri.EscapeDataString(appVersion)}&windows={Uri.EscapeDataString(windows)}"; + if (!string.IsNullOrWhiteSpace(claudeVersion)) url += "&claude-version=" + Uri.EscapeDataString(claudeVersion); + return url; + } + + /// The first line of claude --version, or null when it fails or takes over 5 s. Run on a worker thread. + static async Task ClaudeVersionAsync() + { + // The default profile (~\.claude), as a bare claude in a shell would run; the version does not depend on the account. + var account = new Account("a", null, Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json"), ""); + try + { + var r = await Task.Run(() => ClaudeCli.RunAsync(account, null, ["--version"], TimeSpan.FromSeconds(5))); + return r.Ok && r.Line.Length > 0 ? r.Line : null; + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or IOException) { return null; } // claude not installed + } + + async void OnBugReport(object sender, RoutedEventArgs e) + { + BugButton.IsEnabled = false; // one browser tab per click, however fast the clicks come + try + { + var url = BugReportUrl(AppUpdate.Version, RuntimeInformation.OSDescription, await ClaudeVersionAsync()); + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } + finally { BugButton.IsEnabled = true; } + } } diff --git a/tests/CCWrapper.Tests/Views/BugReportTests.cs b/tests/CCWrapper.Tests/Views/BugReportTests.cs new file mode 100644 index 0000000..b807ebf --- /dev/null +++ b/tests/CCWrapper.Tests/Views/BugReportTests.cs @@ -0,0 +1,31 @@ +using CCWrapper.Core; +using CCWrapper.Views; + +namespace CCWrapper.Tests.Views; + +public sealed class BugReportTests +{ + const string Form = "https://github.com/PieMH/CCWrapper/issues/new?template=bug_report.yml"; + + [Fact] + public void Form_url_is_the_repo_issue_form_with_the_versions_by_field_id() + { + Assert.StartsWith(AppUpdate.Repo + "/issues/new?", Form); + Assert.Equal(Form + "&app-version=0.1.0&windows=Microsoft%20Windows%2010.0.26300&claude-version=2.1.0", + AboutWindow.BugReportUrl("0.1.0", "Microsoft Windows 10.0.26300", "2.1.0")); + } + + [Fact] + public void Spaces_ampersands_and_hashes_are_escaped() + { + Assert.Equal(Form + "&app-version=0.1.0&windows=Windows%20%26%2011%20%232&claude-version=2.1.0%20%23%26", + AboutWindow.BugReportUrl("0.1.0", "Windows & 11 #2", "2.1.0 #&")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Unknown_claude_version_leaves_its_field_out(string? claude) => + Assert.Equal(Form + "&app-version=0.1.0&windows=Windows%2011", AboutWindow.BugReportUrl("0.1.0", "Windows 11", claude)); +} From 315a58a5913436d27d46ef9f3c51e9fe33a2e35a Mon Sep 17 00:00:00 2001 From: PieMH Date: Wed, 28 Oct 2026 15:18:55 +0100 Subject: [PATCH 08/17] T7: Worktree switch in the top bar: new sessions launch with --worktree, worktree tabs are marked and tracked --- src/CCWrapper/Core/LiveSession.cs | 4 ++ src/CCWrapper/Terminal/ClaudeLaunch.cs | 4 +- src/CCWrapper/Views/MainWindow.xaml | 4 ++ src/CCWrapper/Views/MainWindow.xaml.cs | 10 +++- src/CCWrapper/Views/SessionTabs.xaml | 13 +++++ src/CCWrapper/Views/ShellModel.cs | 29 ++++++++++ tests/CCWrapper.Tests/Shell/WorktreeTests.cs | 56 +++++++++++++++++++ .../Terminal/ClaudeLaunchTests.cs | 9 +++ 8 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 tests/CCWrapper.Tests/Shell/WorktreeTests.cs diff --git a/src/CCWrapper/Core/LiveSession.cs b/src/CCWrapper/Core/LiveSession.cs index 947bff9..84c08f8 100644 --- a/src/CCWrapper/Core/LiveSession.cs +++ b/src/CCWrapper/Core/LiveSession.cs @@ -24,6 +24,10 @@ public sealed class LiveSession(Account account) : Observable public string? SessionId { get; set => Set(ref field, value); } public string? Cwd { get; set => Set(ref field, value); } + /// Name of the git worktree the session runs in (--worktree); "…" while launched with the flag but not yet + /// seen in its folder; null when it runs in no worktree. The tab shows a mark while it is set. + public string? Worktree { get; set => Set(ref field, value); } + /// Set at launch (), later refreshed from the status line. public string? TranscriptPath { get; set => Set(ref field, value); } public string? Title { get; set => Set(ref field, value); } diff --git a/src/CCWrapper/Terminal/ClaudeLaunch.cs b/src/CCWrapper/Terminal/ClaudeLaunch.cs index bca13c8..930a1b1 100644 --- a/src/CCWrapper/Terminal/ClaudeLaunch.cs +++ b/src/CCWrapper/Terminal/ClaudeLaunch.cs @@ -9,8 +9,9 @@ namespace CCWrapper.Terminal; /// How to start one claude.exe tab. Optional flags are passed only when set, so the account's own defaults apply. /// Null starts a new session with a fresh --session-id; otherwise --resume <id>. /// With : --fork-session, a copy under a new id that leaves the original alone. +/// --worktree: the new session works in its own git worktree. Ignored for a resume or a copy: those keep the cwd they had. public sealed record LaunchOptions(string Cwd, string? ResumeId = null, string? Model = null, string? Effort = null, - string? PermissionMode = null, string? Name = null, bool Fork = false); + string? PermissionMode = null, string? Name = null, bool Fork = false, bool Worktree = false); /// Command line, environment and statusLine relay for claude.exe. public static class ClaudeLaunch @@ -35,6 +36,7 @@ public static List Args(LaunchOptions o, string sessionId, string? relay Add("--permission-mode", o.PermissionMode); Add("-n", o.Name); Add("--settings", relaySettings); + if (o.Worktree && o.ResumeId is null) args.Add("--worktree"); // last: its optional [name] would swallow a following non-flag argument return args; } diff --git a/src/CCWrapper/Views/MainWindow.xaml b/src/CCWrapper/Views/MainWindow.xaml index 2138801..f9a3165 100644 --- a/src/CCWrapper/Views/MainWindow.xaml +++ b/src/CCWrapper/Views/MainWindow.xaml @@ -21,6 +21,10 @@ + + + + diff --git a/src/CCWrapper/Views/MainWindow.xaml.cs b/src/CCWrapper/Views/MainWindow.xaml.cs index 81dd9ea..9398fcc 100644 --- a/src/CCWrapper/Views/MainWindow.xaml.cs +++ b/src/CCWrapper/Views/MainWindow.xaml.cs @@ -84,6 +84,7 @@ void UpdateCenter() async Task OpenSessionAsync(LiveSession session, LaunchOptions options) { TerminalView.Visibility = Visibility.Visible; // the page sizes a new terminal while it is visible + session.Worktree = ShellModel.WorktreeOf(options.Cwd)?.Name ?? (options.Worktree && options.ResumeId is null ? "…" : null); try { var started = TerminalView.OpenAsync(session, options); // sets SessionId, Cwd and TranscriptPath before its first await @@ -103,7 +104,9 @@ internal async Task NewSessionAsync() { var dialog = new NewSessionDialog { Owner = this }; if (dialog.ShowDialog() == true && dialog.Result is { } options) - await OpenSessionAsync(new LiveSession(_state.Account) { Title = options.Name }, options); + // Only a new session: a resume or a copy keeps the folder it had. A folder outside any repo cannot have a worktree. + await OpenSessionAsync(new LiveSession(_state.Account) { Title = options.Name }, + options with { Worktree = WorktreeSwitch.IsChecked == true && ShellModel.InGitRepo(options.Cwd) }); } /// Shows the live read-only transcript of a session open in another claude process, as the read-only card. @@ -318,6 +321,11 @@ async Task ReadGitAsync() GitAhead.Text = info.Ahead is > 0 ? $"↑{info.Ahead}" : ""; GitBehind.Text = info.Behind is > 0 ? $"↓{info.Behind}" : ""; GitChip.ToolTip = ShellModel.GitTip(info); + var canWorktree = _watched is null || info.InRepo; + WorktreeSwitch.IsEnabled = canWorktree; + WorktreeBox.ToolTip = canWorktree + ? "New sessions in this window start in their own git worktree (claude --worktree). Open tabs and resumed sessions stay where they are." + : "Off for now: the current tab's folder is not a git repository."; } /// The letter on a color circle, the taskbar overlay that tells the windows of different accounts apart. diff --git a/src/CCWrapper/Views/SessionTabs.xaml b/src/CCWrapper/Views/SessionTabs.xaml index 8207470..fba6d38 100644 --- a/src/CCWrapper/Views/SessionTabs.xaml +++ b/src/CCWrapper/Views/SessionTabs.xaml @@ -129,6 +129,19 @@ + + + + + + diff --git a/src/CCWrapper/Views/AboutWindow.xaml.cs b/src/CCWrapper/Views/AboutWindow.xaml.cs index 7f547dc..f764c50 100644 --- a/src/CCWrapper/Views/AboutWindow.xaml.cs +++ b/src/CCWrapper/Views/AboutWindow.xaml.cs @@ -47,26 +47,16 @@ public static string BugReportUrl(string appVersion, string windows, string? cla return url; } - /// The first line of claude --version, or null when it fails or takes over 5 s. Run on a worker thread. - static async Task ClaudeVersionAsync() - { - // The default profile (~\.claude), as a bare claude in a shell would run; the version does not depend on the account. - var account = new Account("a", null, Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json"), ""); - try - { - var r = await Task.Run(() => ClaudeCli.RunAsync(account, null, ["--version"], TimeSpan.FromSeconds(5))); - return r.Ok && r.Line.Length > 0 ? r.Line : null; - } - catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or IOException) { return null; } // claude not installed - } - async void OnBugReport(object sender, RoutedEventArgs e) { BugButton.IsEnabled = false; // one browser tab per click, however fast the clicks come try { - var url = BugReportUrl(AppUpdate.Version, RuntimeInformation.OSDescription, await ClaudeVersionAsync()); - Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + // The version does not depend on the account: the open window's, else the default profile (~\.claude) a bare claude would use. + var account = AppState.Current?.Account ?? new Account("a", null, Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json"), ""); + var url = BugReportUrl(AppUpdate.Version, RuntimeInformation.OSDescription, await ClaudeCli.VersionAsync(account)); + try { Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); } + catch (System.ComponentModel.Win32Exception) { BugNote.Text = "Could not open the browser. Open this address yourself: " + url; BugNote.Visibility = Visibility.Visible; } } finally { BugButton.IsEnabled = true; } } diff --git a/src/CCWrapper/Views/SettingsWindow.xaml.cs b/src/CCWrapper/Views/SettingsWindow.xaml.cs index a6684a9..13d06fa 100644 --- a/src/CCWrapper/Views/SettingsWindow.xaml.cs +++ b/src/CCWrapper/Views/SettingsWindow.xaml.cs @@ -593,7 +593,7 @@ async void ShowAccount() { var account = _account; var (info, facts) = await Task.Run(() => (AccountReader.Read(account), AccountReader.ReadFacts(account))); - _version ??= ClaudeVersionAsync(account); + _version ??= ClaudeCli.VersionAsync(account); if (Page != "account") return; AccountRows.Children.Clear(); void Add(string label, string? value) => AccountRows.Children.Add(FactRow(label, value)); @@ -620,12 +620,6 @@ async void ShowAccount() ((TextBlock)versionRow.Children[1]).Text = await _version ?? "—"; } - static async Task ClaudeVersionAsync(Account account) - { - try { return await ClaudeCli.RunAsync(account, null, ["--version"], TimeSpan.FromSeconds(10)) is { Ok: true } r ? r.Line : null; } - catch (Exception ex) when (ex is Win32Exception or InvalidOperationException) { return null; } // no claude.exe on PATH - } - static void OpenUrl(string url) { // Only http(s) leave for the shell: the URLs are ours, but ShellExecute would run anything. diff --git a/tests/CCWrapper.Tests/Data/AccountReaderTests.cs b/tests/CCWrapper.Tests/Data/AccountReaderTests.cs index a6b047e..bf910c2 100644 --- a/tests/CCWrapper.Tests/Data/AccountReaderTests.cs +++ b/tests/CCWrapper.Tests/Data/AccountReaderTests.cs @@ -82,7 +82,7 @@ static string[] Urls(string? org, string? billing, string? email = "x@example.co AccountLinks.For(new(email, null, null, org, billing, null, null, null, null, null, null)).Select(l => l.Url).ToArray(); Assert.Equal(["https://claude.ai/settings/account", "https://claude.ai/settings/usage", "https://claude.ai/settings/billing"], Urls("claude_max", "stripe")); - Assert.Equal(["https://console.anthropic.com/settings/profile", "https://console.anthropic.com/settings/usage", "https://console.anthropic.com/settings/billing"], Urls("api", "prepaid")); + Assert.Equal(["https://platform.claude.com/", "https://platform.claude.com/usage", "https://platform.claude.com/settings/billing"], Urls("api", "prepaid")); Assert.Equal(["https://claude.ai/settings/account", "https://claude.ai/settings/usage"], Urls(null, null, null)); // nothing known: claude.ai, no Billing } From 58501e917291cef3e1ab24833f6e675e9d9fa0c4 Mon Sep 17 00:00:00 2001 From: PieMH Date: Wed, 28 Oct 2026 15:36:40 +0100 Subject: [PATCH 14/17] F4: README test count 585 -> 595 (badge and xUnit line) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 779b832..18231e1 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-585%20passing-brightgreen) +![Tests](https://img.shields.io/badge/tests-595%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. @@ -228,7 +228,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 585 tests on synthetic fixtures. +- xUnit, with 595 tests on synthetic fixtures. ## Development From bf6b4ed6f19fbc452ab00e8a7252cf5d9487059f Mon Sep 17 00:00:00 2001 From: PieMH Date: Wed, 28 Oct 2026 15:40:27 +0100 Subject: [PATCH 15/17] F5: resume's folder-gone dialog runs deferred, out of the WebView2 handler; About shows the version check while it waits; two style nits in AgentsPanel --- src/CCWrapper/Terminal/TerminalHost.xaml.cs | 3 ++- src/CCWrapper/Views/AboutWindow.xaml.cs | 6 +++++- src/CCWrapper/Views/AgentsPanel.xaml.cs | 3 +-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/CCWrapper/Terminal/TerminalHost.xaml.cs b/src/CCWrapper/Terminal/TerminalHost.xaml.cs index 83179a9..e88cdc5 100644 --- a/src/CCWrapper/Terminal/TerminalHost.xaml.cs +++ b/src/CCWrapper/Terminal/TerminalHost.xaml.cs @@ -69,7 +69,8 @@ public Task ResumeAsync(LiveSession session) var options = ResumeOptions(o, session.Cwd, id); // a copy resumes as itself: its id was adopted if (Directory.Exists(options.Cwd)) return OpenAsync(session, options); // Claude Code may remove an exited --worktree session's folder; same message as a session-list open (MainWindow.FolderGone) - Views.Dialog.Show(Window.GetWindow(this), $"The session's folder does not exist any more:\n{options.Cwd}", "Cannot resume", MessageBoxButton.OK, MessageBoxImage.Warning); + var owner = Window.GetWindow(this); + Dispatcher.BeginInvoke(() => Views.Dialog.Show(owner, $"The session's folder does not exist any more:\n{options.Cwd}", "Cannot resume", MessageBoxButton.OK, MessageBoxImage.Warning)); // not inside the WebView2 message handler: its modal loop would stall the page return Task.CompletedTask; } diff --git a/src/CCWrapper/Views/AboutWindow.xaml.cs b/src/CCWrapper/Views/AboutWindow.xaml.cs index f764c50..1c155a1 100644 --- a/src/CCWrapper/Views/AboutWindow.xaml.cs +++ b/src/CCWrapper/Views/AboutWindow.xaml.cs @@ -50,11 +50,15 @@ public static string BugReportUrl(string appVersion, string windows, string? cla async void OnBugReport(object sender, RoutedEventArgs e) { BugButton.IsEnabled = false; // one browser tab per click, however fast the clicks come + BugNote.Text = "Checking the Claude Code version…"; + BugNote.Visibility = Visibility.Visible; try { // The version does not depend on the account: the open window's, else the default profile (~\.claude) a bare claude would use. var account = AppState.Current?.Account ?? new Account("a", null, Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude.json"), ""); - var url = BugReportUrl(AppUpdate.Version, RuntimeInformation.OSDescription, await ClaudeCli.VersionAsync(account)); + var claudeVersion = await ClaudeCli.VersionAsync(account); + BugNote.Visibility = Visibility.Collapsed; + var url = BugReportUrl(AppUpdate.Version, RuntimeInformation.OSDescription, claudeVersion); try { Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); } catch (System.ComponentModel.Win32Exception) { BugNote.Text = "Could not open the browser. Open this address yourself: " + url; BugNote.Visibility = Visibility.Visible; } } diff --git a/src/CCWrapper/Views/AgentsPanel.xaml.cs b/src/CCWrapper/Views/AgentsPanel.xaml.cs index 5d1d93a..75ac248 100644 --- a/src/CCWrapper/Views/AgentsPanel.xaml.cs +++ b/src/CCWrapper/Views/AgentsPanel.xaml.cs @@ -111,7 +111,7 @@ public sealed record AgentRow(SubagentInfo Info, string Age, string? Duration) public bool IsRunning => Info.IsRunning; public string Title => Info.Description ?? Info.AgentType ?? Info.AgentId; /// "Opus 5.5 · 114.1k tokens · high"; what is unknown is left out. - public string Subtitle => string.Join(" · ", new[] { BarFormat.ModelLabel(Info.Model),Info.Tokens is { } t ? Tokens(t) + " tokens" : null, Info.Effort }.Where(s => !string.IsNullOrEmpty(s))); + public string Subtitle => string.Join(" · ", new[] { BarFormat.ModelLabel(Info.Model), Info.Tokens is { } t ? Tokens(t) + " tokens" : null, Info.Effort }.Where(s => !string.IsNullOrEmpty(s))); /// The hover text: the agent type and the numbers behind the subtitle. public string Tooltip => Detail + "\n" + Stats; public string Detail => string.Join(" · ", new[] { Info.AgentType, ShortModel(Info.Model), Info.Effort }.Where(s => !string.IsNullOrEmpty(s))); @@ -155,5 +155,4 @@ public static bool Matches(SubagentInfo info, string query) /// "claude-opus-4-5-20251101" -> "opus-4-5". public static string? ShortModel(string? model) => model is null ? null : Regex.Replace(model, @"^claude-|-\d{8}$", ""); - } From 9e47fae2fa9f187db46afeeada529aefb02a79a4 Mon Sep 17 00:00:00 2001 From: PieMH Date: Wed, 28 Oct 2026 18:04:41 +0100 Subject: [PATCH 16/17] Model chooser reads Claude Code's cached model catalog instead of a hand-kept table The main models of the newest cache/model-catalog/*-cc.json give the names with versions and Fable's "Requires usage credits" badge; with no catalog the popup offers the bare aliases. README test count 595 -> 602. --- CLAUDE.md | 2 +- README.md | 6 +-- src/CCWrapper/Data/ModelCatalog.cs | 50 +++++++++++++++++ src/CCWrapper/Views/BottomBar.xaml.cs | 26 +++++---- .../CCWrapper.Tests/Data/ModelCatalogTests.cs | 53 +++++++++++++++++++ tests/CCWrapper.Tests/Shell/BarChoiceTests.cs | 29 ++++++---- 6 files changed, 141 insertions(+), 25 deletions(-) create mode 100644 src/CCWrapper/Data/ModelCatalog.cs create mode 100644 tests/CCWrapper.Tests/Data/ModelCatalogTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 704da30..044edf6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ dotnet test |---|---| | `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; `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 and their `argument-hint`s, which the compose box greys after the command), `UsageStats` (the usage popup's token counts); `SettingsData`, the Settings window's load-edit-save of `settings.json` and `CLAUDE.md`; `AccountReader` (the Account page's facts: `.claude.json` identity and the read-only "other configuration", with a deny-list for secret-like keys); `PluginData` (also a plugin's manifest from `known_marketplaces.json` and `marketplace.json`, and `SourceUrl`, which yields only http/https); `ClaudeJson`, locked updates of `.claude.json` | +| `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 and their `argument-hint`s, which the compose box greys after the command), `UsageStats` (the usage popup's token counts), `ModelCatalog` (the model chooser's list: the main models of the newest `cache\model-catalog\*-cc.json`, the catalog Claude Code fetches per account); `SettingsData`, the Settings window's load-edit-save of `settings.json` and `CLAUDE.md`; `AccountReader` (the Account page's facts: `.claude.json` identity and the read-only "other configuration", with a deny-list for secret-like keys); `PluginData` (also a plugin's manifest from `known_marketplaces.json` and `marketplace.json`, and `SourceUrl`, which yields only http/https); `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 | diff --git a/README.md b/README.md index 18231e1..a63db04 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-595%20passing-brightgreen) +![Tests](https://img.shields.io/badge/tests-602%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. @@ -98,7 +98,7 @@ Confirmations, warnings and errors use the app's dark theme too. - **Browser:** a built-in WebView2 browser for your dev server, with tabs (the **+** button opens one, up to 20; tabs are not restored after a restart). A pop-up a click opens appears in a new tab; pop-ups a page opens on its own are dropped (a page can still open one in the few seconds after a click, Claude's clicks included, as in any browser), and a page's `window.close()` closes only pop-up tabs. Claude browses in it too: every session can navigate, read the page, click, type, take screenshots, run scripts, use DevTools, and list, open, switch and close tabs there, and is told to prefer it over another browser. Ask for Chrome by name and it uses Chrome. Scripts and DevTools act with the page's own logins in that profile; DevTools refuses cookie access, downloads, local files, all simulated input (keyboard, mouse, touch and drag, so it cannot paste your clipboard), the page history and scripts that persist across pages. Claude opens only http, https, blob, about:blank and data addresses, never local files (a pop-up to any other address is dropped), and its tools refuse to work on a page that is not a web address (for example a `file:` page you opened yourself). Its tools act on the tab in front. To turn this off, clear **Let Claude use the Browser panel** in Settings → CCWrapper. **Bottom bar** -- Model, effort and permission-mode choosers that drive Claude Code's own `/model`, `/effort` and Shift+Tab. Each opens as a popup like the usage one and closes on a click elsewhere or Esc. Models show with their versions (Opus 5.5, Sonnet 5.5, Haiku 5.5, Fable 5.1, which runs on usage credits). The effort popup also has an **Ultracode** switch, off by default, that sends `/effort ultracode on` or `off`. +- Model, effort and permission-mode choosers that drive Claude Code's own `/model`, `/effort` and Shift+Tab. Each opens as a popup like the usage one and closes on a click elsewhere or Esc. The model list is the one Claude Code caches for the account, with its versions and notes (Fable: "Requires usage credits"); with none cached yet it offers the bare aliases. The effort popup also has an **Ultracode** switch, off by default, that sends `/effort ultracode on` or `off`. - 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. @@ -228,7 +228,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 595 tests on synthetic fixtures. +- xUnit, with 602 tests on synthetic fixtures. ## Development diff --git a/src/CCWrapper/Data/ModelCatalog.cs b/src/CCWrapper/Data/ModelCatalog.cs new file mode 100644 index 0000000..d5679cf --- /dev/null +++ b/src/CCWrapper/Data/ModelCatalog.cs @@ -0,0 +1,50 @@ +using System.IO; +using System.Text.Json; +using CCWrapper.Core; + +namespace CCWrapper.Data; + +/// One model of Claude Code's /model list. is its family ("opus"), the /model argument; +/// the versioned name ("Opus 5.5"); Claude Code's badge ("Requires usage credits"). +public sealed record CatalogModel(string Id, string Alias, string Name, string? Note); + +/// The model list Claude Code fetches for the signed-in account and caches in cache\model-catalog. Undocumented, +/// so every field is optional. +public static class ModelCatalog +{ + /// The main models of the newest cached catalog of the CLI (*-cc.json; -ccd is the desktop app's); + /// empty when there is none (an API key, before the first start). Reads files: off the UI thread. + public static IReadOnlyList Read(Account account) + { + IReadOnlyList best = []; + double newest = 0; + try + { + foreach (var file in Directory.EnumerateFiles(Path.Combine(account.ConfigRoot, "cache", "model-catalog"), "*-cc.json")) + { + using var doc = JsonUtil.Load(file); + var root = doc?.RootElement ?? default; + if (root.Prop("fetchedAt").Num() is not { } at || at <= newest) continue; + if (Parse(root) is { Count: > 0 } models) (best, newest) = (models, at); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } // no folder yet, or Claude Code rewriting it + return best; + } + + /// The "main" section's models, the first of each family; the older versions ("overflow") are left out. + internal static List Parse(JsonElement root) + { + var list = new List(); + var models = root.Prop("catalog").Prop("config").Prop("models"); + if (models.ValueKind != JsonValueKind.Array) return list; + foreach (var m in models.EnumerateArray()) + { + if (m.Prop("section").Str() != "main" || m.Prop("id").Str() is not { } id || m.Prop("name").Str() is not { } name) continue; + var alias = m.Prop("runtime").Prop("family").Str() ?? m.Prop("short_name").Str()?.ToLowerInvariant(); + if (alias is { Length: > 0 } && !list.Any(x => x.Alias == alias)) + list.Add(new(id, alias, name, m.Prop("badge").Prop("message").Str())); + } + return list; + } +} diff --git a/src/CCWrapper/Views/BottomBar.xaml.cs b/src/CCWrapper/Views/BottomBar.xaml.cs index 85e1217..0acf686 100644 --- a/src/CCWrapper/Views/BottomBar.xaml.cs +++ b/src/CCWrapper/Views/BottomBar.xaml.cs @@ -25,6 +25,7 @@ public partial class BottomBar : UserControl (LiveSession Session, string? Mode) _screenMode; string? _model, _effort, _mode; AccountInfo? _account; // the window's account, read at start and after each /usage check + IReadOnlyList _models = []; // Claude Code's cached model list, read with _account bool _checkingUsage; Func _usageTip = () => ""; (double? Five, double? Week, DateTimeOffset? FiveReset, DateTimeOffset? WeekReset) _usageNow; @@ -95,7 +96,7 @@ void Render() _effort = sl?.Effort ?? d?.Effort ?? d?.Account?.Effort; // The transcript's permission-mode record is written per turn, not when Shift+Tab changes the mode: the footer is the truth. _mode = (_screenMode.Session == s ? _screenMode.Mode : null) ?? d?.Mode; - ModelValue.Text = BarFormat.ModelLabel(_model) ?? BarFormat.None; + ModelValue.Text = BarFormat.ModelLabel(_model, _models) ?? BarFormat.None; EffortValue.Text = _effort ?? BarFormat.None; ModeValue.Text = _mode ?? BarFormat.None; @@ -335,13 +336,13 @@ void OnUsageRefreshed(Account account) async Task ReadAccountAsync() { if (AppState.Current?.Account is not { } account) return; - _account = await Task.Run(() => AccountReader.Read(account)); + (_account, _models) = await Task.Run(() => (AccountReader.Read(account), ModelCatalog.Read(account))); Render(); } // A refusal (e.g. text already in the terminal input) shows in the compose box's notice. void OnModel(object sender, RoutedEventArgs e) => - Open(ModelButton, BarFormat.Models.Select(m => (m.Alias, m.Label, m.Note)), v => _model?.Contains(v, StringComparison.OrdinalIgnoreCase) == true, v => TuiInput.SubmitAsync(_session, "/model " + v)); + Open(ModelButton, BarFormat.ModelRows(_models), v => _model?.Contains(v, StringComparison.OrdinalIgnoreCase) == true, v => TuiInput.SubmitAsync(_session, "/model " + v)); void OnEffort(object sender, RoutedEventArgs e) => Open(EffortButton, Plain(Efforts), v => v == _effort, v => TuiInput.SubmitAsync(_session, "/effort " + v), ultracode: true); @@ -555,11 +556,12 @@ public static (string Text, double? Percent) CtxParts(StatusLineData? sl, long? return (used is null && size is null && pct is null ? None : $"{Tokens(used)}/{Tokens(size)} {Percent(pct)}", pct); } - // shortcut: hand-kept, as Claude Code exposes no list of models and versions; upgrade if it does. Alias is the /model argument. - public static readonly (string Alias, string Label, string? Note)[] Models = - [ - ("opus", "Opus 5.5", null), ("sonnet", "Sonnet 5.5", null), ("haiku", "Haiku 5.5", null), ("fable", "Fable 5.1", "runs on usage credits"), - ]; + // No catalog cached (an API key, before the first start): the documented /model aliases, with no version to go stale. + static readonly CatalogModel[] Aliases = [new("opus", "opus", "Opus", null), new("sonnet", "sonnet", "Sonnet", null), new("haiku", "haiku", "Haiku", null)]; + + /// The model chooser's rows (alias, name, note): Claude Code's own model list, else the bare aliases. + public static IEnumerable<(string Alias, string Label, string? Note)> ModelRows(IReadOnlyList catalog) => + (catalog.Count > 0 ? catalog : Aliases).Select(m => (m.Alias, m.Name, m.Note)); static readonly Regex ModelId = new(@"^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$"); @@ -569,9 +571,11 @@ public static readonly (string Alias, string Label, string? Note)[] Models = ? $"{char.ToUpperInvariant(m.Groups[1].Value[0])}{m.Groups[1].Value[1..]} {m.Groups[2].Value}{(m.Groups[3].Success ? "." + m.Groups[3].Value : "")}" : null; - /// The versioned name of a bare alias ("opus" gives "Opus 5.5") or of a full id; anything else (a display name) as it is. - public static string? ModelLabel(string? model) => - Models.Where(m => m.Alias.Equals(model, StringComparison.OrdinalIgnoreCase)).Select(m => m.Label).FirstOrDefault() ?? ModelName(model) ?? model; + /// The versioned name of an alias or id in ("opus" gives "Opus 5.5"), else of a full id; + /// anything else (a display name) as it is. + public static string? ModelLabel(string? model, IReadOnlyList? catalog = null) => + catalog?.FirstOrDefault(m => m.Alias.Equals(model, StringComparison.OrdinalIgnoreCase) || m.Id.Equals(model, StringComparison.OrdinalIgnoreCase))?.Name + ?? ModelName(model) ?? model; static readonly Dictionary FooterModes = new() { diff --git a/tests/CCWrapper.Tests/Data/ModelCatalogTests.cs b/tests/CCWrapper.Tests/Data/ModelCatalogTests.cs new file mode 100644 index 0000000..8c7b115 --- /dev/null +++ b/tests/CCWrapper.Tests/Data/ModelCatalogTests.cs @@ -0,0 +1,53 @@ +using System.IO; +using CCWrapper.Data; + +namespace CCWrapper.Tests.Data; + +public sealed class ModelCatalogTests : IDisposable +{ + readonly FakeHome _home = new(); + + public void Dispose() => _home.Dispose(); + + static string Catalog(double fetchedAt, string models) => + $$"""{"version":2,"fetchedAt":{{fetchedAt}},"catalog":{"surface":"cc","config":{"id":"cc","models":[""" + models + "]}}}"; + + const string Main = """ + {"id":"claude-opus-9-1","name":"Opus 9.1","short_name":"Opus","section":"main","runtime":{"family":"opus"}}, + {"id":"claude-fable-9","name":"Fable 9","short_name":"Fable","section":"main","badge":{"message":"Requires usage credits"}}, + {"id":"claude-haiku-9","name":"Haiku 9","short_name":"Haiku","section":"main"}, + {"id":"claude-haiku-8","name":"Haiku 8","short_name":"Haiku","section":"main"}, + {"id":"claude-opus-9","name":"Opus 9","short_name":"Opus","section":"overflow","runtime":{"family":"opus"}}, + {"name":"no id","section":"main"} + """; + + [Fact] + public void Main_models_one_per_family_with_their_badge() + { + var a = _home.AddAccount("c"); + FakeHome.WriteFile(Path.Combine(a.ConfigRoot, "cache", "model-catalog", "org-x-cc.json"), Catalog(1000, Main)); + + Assert.Equal( + [ + new CatalogModel("claude-opus-9-1", "opus", "Opus 9.1", null), + new CatalogModel("claude-fable-9", "fable", "Fable 9", "Requires usage credits"), + new CatalogModel("claude-haiku-9", "haiku", "Haiku 9", null), + ], ModelCatalog.Read(a)); + } + + [Fact] + public void Newest_cli_catalog_wins_and_the_desktop_one_is_ignored() + { + var a = _home.AddAccount("c"); + var dir = Path.Combine(a.ConfigRoot, "cache", "model-catalog"); + FakeHome.WriteFile(Path.Combine(dir, "old-org-cc.json"), Catalog(1000, """{"id":"claude-opus-8","name":"Opus 8","short_name":"Opus","section":"main"}""")); + FakeHome.WriteFile(Path.Combine(dir, "org-cc.json"), Catalog(2000, """{"id":"claude-opus-9","name":"Opus 9","short_name":"Opus","section":"main"}""")); + FakeHome.WriteFile(Path.Combine(dir, "org-ccd.json"), Catalog(3000, """{"id":"claude-opus-10","name":"Opus 10","short_name":"Opus","section":"main"}""")); + FakeHome.WriteFile(Path.Combine(dir, "broken-cc.json"), "{not json"); + + Assert.Equal("Opus 9", Assert.Single(ModelCatalog.Read(a)).Name); + } + + [Fact] + public void No_catalog_is_an_empty_list() => Assert.Empty(ModelCatalog.Read(_home.AddAccount("c"))); +} diff --git a/tests/CCWrapper.Tests/Shell/BarChoiceTests.cs b/tests/CCWrapper.Tests/Shell/BarChoiceTests.cs index be1f091..bcbba25 100644 --- a/tests/CCWrapper.Tests/Shell/BarChoiceTests.cs +++ b/tests/CCWrapper.Tests/Shell/BarChoiceTests.cs @@ -1,19 +1,29 @@ +using CCWrapper.Data; using CCWrapper.Views; namespace CCWrapper.Tests.Shell; public sealed class BarChoiceTests { + static readonly CatalogModel[] Catalog = + [new("claude-opus-9-1", "opus", "Opus 9.1", null), new("claude-fable-9", "fable", "Fable 9", "Requires usage credits")]; + [Theory] - [InlineData("opus", "Opus 5.5")] - [InlineData("Fable", "Fable 5.1")] + [InlineData("opus", "Opus 9.1")] // the catalog's version, not one kept in the app + [InlineData("Fable", "Fable 9")] + [InlineData("claude-fable-9", "Fable 9")] [InlineData("Opus 5.5", "Opus 5.5")] // a display name is left alone - [InlineData("claude-opus-5-5", "Opus 5.5")] // a full id gets its version too (was raw before F1) - [InlineData("claude-sonnet-5-5", "Sonnet 5.5")] - [InlineData("claude-fable-5-1", "Fable 5.1")] + [InlineData("claude-sonnet-5-5", "Sonnet 5.5")] // a full id outside the catalog gets its version from the id + [InlineData("haiku", "haiku")] [InlineData("gpt-5", "gpt-5")] [InlineData(null, null)] - public void Bare_model_aliases_and_full_ids_get_their_version(string? model, string? label) => Assert.Equal(label, BarFormat.ModelLabel(model)); + public void Aliases_and_ids_get_the_catalog_name(string? model, string? label) => Assert.Equal(label, BarFormat.ModelLabel(model, Catalog)); + + [Theory] + [InlineData("opus", "opus")] // no catalog: no version to guess + [InlineData("claude-opus-5-5", "Opus 5.5")] + [InlineData("claude-fable-5-1", "Fable 5.1")] + public void Without_a_catalog_only_full_ids_get_a_version(string model, string label) => Assert.Equal(label, BarFormat.ModelLabel(model)); [Theory] [InlineData("claude-opus-5-5-20260101", "Opus 5.5")] @@ -24,11 +34,10 @@ public sealed class BarChoiceTests public void Model_name_reads_any_family_from_a_full_id(string id, string? name) => Assert.Equal(name, BarFormat.ModelName(id)); [Fact] - public void Model_table_covers_the_aliases_and_only_fable_has_a_note() + public void Model_rows_are_the_catalog_else_the_bare_aliases() { - Assert.Equal(["opus", "sonnet", "haiku", "fable"], BarFormat.Models.Select(m => m.Alias)); - Assert.Equal(["fable"], BarFormat.Models.Where(m => m.Note is not null).Select(m => m.Alias)); - Assert.All(BarFormat.Models, m => Assert.Contains(m.Alias, m.Label, StringComparison.OrdinalIgnoreCase)); + Assert.Equal(new (string, string, string?)[] { ("opus", "Opus 9.1", null), ("fable", "Fable 9", "Requires usage credits") }, BarFormat.ModelRows(Catalog)); + Assert.Equal(new (string, string, string?)[] { ("opus", "Opus", null), ("sonnet", "Sonnet", null), ("haiku", "Haiku", null) }, BarFormat.ModelRows([])); } [Theory] From 383e60c328c0478c7a543f051480055d96d6f841 Mon Sep 17 00:00:00 2001 From: PieMH Date: Wed, 28 Oct 2026 18:04:42 +0100 Subject: [PATCH 17/17] A /rename in the TUI renames the tab Adopt took the registry name only when the session id changed. It now follows it on every poll; a name Claude Code derived itself is left out. --- src/CCWrapper/Data/SessionCatalog.cs | 3 ++- src/CCWrapper/Views/ShellModel.cs | 6 +++--- tests/CCWrapper.Tests/Data/SessionCatalogTests.cs | 8 ++++++++ tests/CCWrapper.Tests/Shell/ShellTests.cs | 8 +++++--- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/CCWrapper/Data/SessionCatalog.cs b/src/CCWrapper/Data/SessionCatalog.cs index e3fc2f8..77555a0 100644 --- a/src/CCWrapper/Data/SessionCatalog.cs +++ b/src/CCWrapper/Data/SessionCatalog.cs @@ -9,6 +9,7 @@ namespace CCWrapper.Data; /// One live claude process from sessions\<pid>.json (alive pids only). +/// The name given with -n or /rename; one Claude Code derived itself ("ccwrapper-2a") is left out. public sealed record RunningSession( int Pid, string SessionId, string? Cwd, string? Name, SessionStatus Status, string? WaitingFor, string? Kind, DateTimeOffset? UpdatedAt); @@ -70,7 +71,7 @@ public static IReadOnlyList Running(Account account) if (!Alive((int)pid, r.Prop("procStart").Str())) continue; var waiting = r.Prop("waitingFor"); result.Add(new RunningSession( - (int)pid, sessionId, r.Prop("cwd").Str(), r.Prop("name").Str(), + (int)pid, sessionId, r.Prop("cwd").Str(), r.Prop("nameSource").Str() == "derived" ? null : r.Prop("name").Str(), Enum.TryParse(r.Prop("status").Str(), ignoreCase: true, out var status) ? status : SessionStatus.Unknown, waiting.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null ? null : waiting.Str() ?? waiting.GetRawText(), r.Prop("kind").Str(), JsonUtil.Ms(r.Prop("updatedAt")))); diff --git a/src/CCWrapper/Views/ShellModel.cs b/src/CCWrapper/Views/ShellModel.cs index 6382254..8bdef23 100644 --- a/src/CCWrapper/Views/ShellModel.cs +++ b/src/CCWrapper/Views/ShellModel.cs @@ -132,10 +132,11 @@ public static bool InGitRepo(string folder) return false; } - /// A session whose registry entry carries another id (after /clear or /resume inside the TUI) follows it: - /// new id, its transcript and the registry name as title. + /// A session follows its registry entry: the name as title (a /rename in the TUI included) and, when the entry carries + /// another id (after /clear or /resume inside the TUI), that id and its transcript. public static void Adopt(LiveSession session, RunningSession registry) { + if (registry.Name is { Length: > 0 } name) session.Title = name; // --worktree: Claude Code moved into the worktree, so the transcript is under its project folder, not the launch folder's. // Any other session in a worktree keeps its launch folder (and transcript); it only gets the mark. if (WorktreeOf(registry.Cwd) is var (worktree, root)) @@ -150,7 +151,6 @@ public static void Adopt(LiveSession session, RunningSession registry) if (session.SessionId == registry.SessionId) return; session.SessionId = registry.SessionId; if (session.Cwd is { } cwd) session.TranscriptPath = session.Account.Paths.Transcript(cwd, registry.SessionId); - if (registry.Name is { Length: > 0 } name) session.Title = name; } } diff --git a/tests/CCWrapper.Tests/Data/SessionCatalogTests.cs b/tests/CCWrapper.Tests/Data/SessionCatalogTests.cs index 42302d3..35db1f6 100644 --- a/tests/CCWrapper.Tests/Data/SessionCatalogTests.cs +++ b/tests/CCWrapper.Tests/Data/SessionCatalogTests.cs @@ -143,6 +143,14 @@ public void Running_registry_keeps_live_pids_and_merges_into_the_list() Assert.Null(SessionCatalog.List(x).Single().Running); } + [Fact] + public void Running_leaves_out_a_name_claude_code_derived_itself() + { + var a = _home.AddAccount("a"); + FakeHome.AddRegistry(a, Self, "live1", extra: ",\"nameSource\":\"derived\""); + Assert.Null(Assert.Single(SessionCatalog.Running(a)).Name); + } + [Fact] public void Recent_projects_merge_global_config_and_transcripts_and_drop_missing_folders() { diff --git a/tests/CCWrapper.Tests/Shell/ShellTests.cs b/tests/CCWrapper.Tests/Shell/ShellTests.cs index 16a6ab6..b3ad8d7 100644 --- a/tests/CCWrapper.Tests/Shell/ShellTests.cs +++ b/tests/CCWrapper.Tests/Shell/ShellTests.cs @@ -150,12 +150,14 @@ public void MapStatus_matches_by_pid_when_the_session_id_changed_and_adopts_the_ } [Fact] - public void Adopt_leaves_a_session_with_the_same_id_alone() + public void Adopt_takes_a_rename_but_leaves_the_id_and_transcript_of_the_same_session() { var tab = Live("s1", pid: 10); tab.Title = "Mine"; - ShellModel.Adopt(tab, Reg("s1", SessionStatus.Idle, pid: 10, name: "Registry name")); - Assert.Equal(("s1", "Mine", null), (tab.SessionId, tab.Title, tab.TranscriptPath)); + ShellModel.Adopt(tab, Reg("s1", SessionStatus.Idle, pid: 10)); + Assert.Equal("Mine", tab.Title); // no name in the registry: the tab keeps its own + ShellModel.Adopt(tab, Reg("s1", SessionStatus.Idle, pid: 10, name: "Renamed")); // /rename in the TUI + Assert.Equal(("s1", "Renamed", null), (tab.SessionId, tab.Title, tab.TranscriptPath)); } [Fact]