Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ body:
attributes:
label: CCWrapper version
description: Shown under the name in the About window.
placeholder: "0.1.0"
placeholder: "1.2.0"
validations:
required: true
- type: input
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ dotnet test
or tab has the session; it refuses any path outside the account's `projects\<dir>\<id>.jsonl`.
- **The browser tool drives only the Browser panel's WebView2** (its own user-data folder) through in-process CDP, one WebView2 per tab on the panel's one profile. Never the
terminal's WebView2, never a remote debugging port. The pipe is `CurrentUserOnly`, and the only launch flags it adds for the
tool are `--mcp-config` and `--append-system-prompt` (`--worktree` comes from the top bar's Worktree switch, not from the tool). `browserTool` off: new tabs get no flags and `BrowserMcp` refuses calls from open ones.
tool are `--mcp-config` and `--append-system-prompt` (`--worktree` comes from the New session dialog's Worktree switch, not from the tool). `browserTool` off: new tabs get no flags and `BrowserMcp` refuses calls from open ones.
Hardened: each tools/call has a 60 s limit, `navigate`, `new_tab` and `window.open` (which opens a tab only when user-initiated, i.e. within Chromium's few seconds of activation after a click, a CDP click from `click` included, max 20, else dropped; `window.close()` closes only such pop-up tabs, and `DropControlsCloseHandler` stops WPF's WebView2 closing the whole window on it) pass only http, https, blob,
about:blank and data (`BrowserPanel.SchemeAllowed`), every page tool but `navigate` refuses while the front tab shows any other page (`OnUi` captures the front tab once per call and runs both checks, before and after the body, on that tab, whatever got it
there: history, the address bar; `tabs` lists such a tab as "(not a web page)", without title or URL), and `devtools` is an allow-list by domain (`BrowserMcp.DevToolsRefusal`) that denies cookies,
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-602%20passing-brightgreen)
![Tests](https://img.shields.io/badge/tests-609%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.

Expand Down Expand Up @@ -36,7 +36,7 @@ It stays light: a native WPF app (no Electron), Windows' own icon font, a dark s
- Each window runs one account with that account's own settings. To run accounts side by side, open more windows.
- **Switch** in the top bar restarts the window as another account. Its sessions stay resumable.
- The top bar also shows the repo's git state: the GitHub account it pushes as, the branch, and the commits to push (↑) and pull (↓) as of the last fetch.
- **Worktree** in the top bar, off by default, per window and not saved: while it is on, new sessions in a git repo start with `claude --worktree`, in a worktree of their own. Open tabs, resumes and copies are not affected. A worktree tab carries a mark, and the switch is disabled outside a git repo.
- **Worktree** in the New session dialog, off by default and only for a folder in a git repo: the session starts with `claude --worktree`, in a worktree of its own. A session already running can move into one when you ask Claude to (Claude Code's own worktree tool). A worktree tab carries a mark.
- `CCWrapper.exe --account b` skips the picker, so you can make one shortcut per account.
- The taskbar badge shows each window's account letter and color.

Expand Down Expand Up @@ -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. 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`.
- 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`. When Claude Code refuses it (it needs dynamic workflows on in `/config`), the switch flips back and the message box shows Claude Code's reason.
- 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.

Expand Down Expand Up @@ -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 602 tests on synthetic fixtures.
- xUnit, with 609 tests on synthetic fixtures.

## Development

Expand Down
2 changes: 1 addition & 1 deletion src/CCWrapper/CCWrapper.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<Version>0.1.0</Version>
<Version>1.2.0</Version>
<!-- Program.Main starts Velopack first; the Main that App.xaml generates stays unused. -->
<StartupObject>CCWrapper.Program</StartupObject>
<!-- Embedded in the exe: Explorer, Start, taskbar; WPF windows without an Icon take it from the exe too. -->
Expand Down
17 changes: 17 additions & 0 deletions src/CCWrapper/Data/TranscriptReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,23 @@ static string ToolSummary(JsonElement toolUse)
return $"{name}: {(line.Length > 120 ? line[..120] + "…" : line)}";
}

/// <summary>What local commands (/effort, /color…) printed, recorded from byte <paramref name="from"/> on, oldest first. Claude Code
/// wraps it in &lt;local-command-stdout&gt;, as a "local_command" system record's content or as a user message. A missing file gives none.</summary>
public static List<string> LocalCommandOutputs(string path, long from)
{
const string Open = "<local-command-stdout>", Close = "</local-command-stdout>";
var outputs = new List<string>();
try
{
foreach (var (rec, _) in JsonlReader.Read(path, from))
if ((Str(rec, "content") ?? Str(Prop(rec, "message"), "content")) is { } text && text.StartsWith(Open, StringComparison.Ordinal)
&& text.IndexOf(Close, StringComparison.Ordinal) is > 0 and var end)
outputs.Add(text[Open.Length..end].Trim());
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } // not written yet, or being replaced
return outputs;
}

/// <summary>Null-safe reads of Claude Code's undocumented JSON: a wrong or missing shape is just null.
/// Plain static methods on purpose: extension methods here would clash with other Data helpers.</summary>
internal static class Get
Expand Down
28 changes: 27 additions & 1 deletion src/CCWrapper/Views/BottomBar.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Globalization;
using System.IO;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Automation;
Expand Down Expand Up @@ -428,15 +429,32 @@ void Show(bool v)
busy = true;
var want = sw.IsChecked != true;
Show(want);
// A refusal shows in the compose box's notice, as for the other picks.
var transcript = s?.TranscriptPath;
var from = transcript is not null && File.Exists(transcript) ? new FileInfo(transcript).Length : 0;
// A refusal, the app's or Claude Code's own, shows in the compose box's notice, as for the other picks.
var refusal = await TuiInput.SubmitAsync(s, $"/effort ultracode {(want ? "on" : "off")}");
if (refusal is null && s is not null && await UltracodeRefusalAsync(transcript, from) is { } refused)
TuiInput.Refused(s, refusal = refused);
if (refusal is null && s is not null) _ultraSent[s] = want;
else Show(!want);
busy = false;
};
ChoiceBody.Children.Add(row);
}

/// <summary>Claude Code's refusal of the /effort ultracode just sent, from the transcript written after byte <paramref name="from"/>;
/// null when it took it, or when no reply came within ~3 s (it may be queued behind a turn: then the switch trusts the send).</summary>
static async Task<string?> UltracodeRefusalAsync(string? transcript, long from)
{
for (var i = 0; i < 20 && transcript is not null; i++)
{
await Task.Delay(150);
var outputs = await Task.Run(() => TranscriptReader.LocalCommandOutputs(transcript, from));
if (outputs.Select(BarFormat.UltracodeReply).FirstOrDefault(r => r is not null) is { } reply) return reply.Refusal;
}
return null;
}

/// <summary>The mode in the terminal's footer (see <see cref="BarFormat.ModeFromScreen"/>), also kept for display; null if
/// there is no terminal or no footer.</summary>
async Task<string?> ReadScreenModeAsync(LiveSession s)
Expand Down Expand Up @@ -595,6 +613,14 @@ public static (string Text, double? Percent) CtxParts(StatusLineData? sl, long?
screen is not null && FooterLines(screen).Select(l => Regex.Match(l, @"(?:^\W*|·\s*)ultracode (on|off)\b", RegexOptions.IgnoreCase)).FirstOrDefault(m => m.Success) is { } m
? m.Groups[1].Value.Equals("on", StringComparison.OrdinalIgnoreCase) : null;

/// <summary>Claude Code's reply to /effort ultracode: null for any other output; else whether it took it and, when not, the reason it
/// gave ("Ultracode needs dynamic workflows enabled (see /config).", "Ultracode isn't available on …").</summary>
public static (bool Taken, string? Refusal)? UltracodeReply(string output) =>
output.StartsWith("Ultracode on", StringComparison.Ordinal) || output.StartsWith("Ultracode off", StringComparison.Ordinal) ? (true, null)
: output.StartsWith("Ultracode ", StringComparison.Ordinal) && output.IndexOf(" Valid options are:", StringComparison.Ordinal) is > 0 and var end
? (false, output[..end])
: null;

/// <summary>The permission mode named by the TUI's footer ("⏵⏵ auto mode on (shift+tab to cycle)") among the last non-blank
/// rows of the terminal text, or null. These are Claude Code's own indicator names; the default mode shows none, only
/// "? for shortcuts".</summary>
Expand Down
3 changes: 3 additions & 0 deletions src/CCWrapper/Views/ComposeBox.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,9 @@ s is null ? "No active session"
static string WaitingText(string? waitingFor) =>
$"Claude is waiting ({(string.IsNullOrEmpty(waitingFor) ? "dialog open" : waitingFor)}): answer it in the terminal";

/// <summary>Claude Code itself refused a command that went in (<paramref name="reason"/> is its reply): shown like a refused submit.</summary>
public static void Refused(LiveSession s, string reason) => Changed?.Invoke(s, reason);

/// <summary>The bottom bar's Shift+Tab loop runs: <see cref="BlockedReason"/> says so until it ends.</summary>
public static void SetSwitching(LiveSession s, bool on)
{
Expand Down
4 changes: 0 additions & 4 deletions src/CCWrapper/Views/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,6 @@
<TextBlock x:Name="GitBehind" Foreground="{StaticResource Warn}" Margin="4,0,0,0" VerticalAlignment="Center" />
</StackPanel>
</Border>
<StackPanel x:Name="WorktreeBox" DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,12,0" Background="Transparent">
<TextBlock Text="Worktree" Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<ToggleButton x:Name="WorktreeSwitch" Style="{StaticResource Switch}" Margin="4,0,0,0" AutomationProperties.Name="Start new sessions in a git worktree" />
</StackPanel>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Image Source="/logo.png" Width="20" Height="20" RenderOptions.BitmapScalingMode="HighQuality" />
<TextBlock Text="CCWrapper" FontWeight="SemiBold" Margin="8,0,0,0" VerticalAlignment="Center" />
Expand Down
9 changes: 1 addition & 8 deletions src/CCWrapper/Views/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,7 @@ internal async Task NewSessionAsync()
{
var dialog = new NewSessionDialog { Owner = this };
if (dialog.ShowDialog() == true && dialog.Result is { } 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) });
await OpenSessionAsync(new LiveSession(_state.Account) { Title = options.Name }, options);
}

/// <summary>Shows the live read-only transcript of a session open in another claude process, as the read-only card.</summary>
Expand Down Expand Up @@ -322,11 +320,6 @@ 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.";
}

/// <summary>The letter on a color circle, the taskbar overlay that tells the windows of different accounts apart.</summary>
Expand Down
9 changes: 9 additions & 0 deletions src/CCWrapper/Views/NewSessionDialog.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@
<TextBlock Text="Name (optional)" Margin="0,14,0,4" />
<TextBox x:Name="SessionName" />

<DockPanel Margin="0,14,0,0">
<ToggleButton x:Name="WorktreeSwitch" DockPanel.Dock="Right" Style="{StaticResource Switch}" VerticalAlignment="Center" IsEnabled="False"
AutomationProperties.Name="Start in a git worktree" />
<StackPanel Margin="0,0,12,0">
<TextBlock Text="Worktree" />
<TextBlock x:Name="WorktreeNote" Text="Only for a folder in a git repository." Foreground="{StaticResource Muted}" FontSize="11" TextWrapping="Wrap" Margin="0,2,0,0" />
</StackPanel>
</DockPanel>

<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,20,0,0">
<Button Content="Cancel" IsCancel="True" Padding="16,4" Margin="0,0,8,0" />
<Button x:Name="StartButton" Style="{StaticResource Primary}" Content="Start" IsDefault="True" IsEnabled="False" Click="OnStart" />
Expand Down
16 changes: 13 additions & 3 deletions src/CCWrapper/Views/NewSessionDialog.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

namespace CCWrapper.Views;

/// <summary>Folder, model, effort, permission mode and name of a new session. A combo left on "(account default…)"
/// <summary>Folder, model, effort, permission mode, name and worktree (off by default) of a new session. A combo left on "(account default…)"
/// gives null, so claude.exe gets no flag and the account's own setting applies.</summary>
public partial class NewSessionDialog : Window
{
Expand Down Expand Up @@ -50,7 +50,17 @@ static void Fill(ComboBox box, string[] items, string? accountDefault)

string FolderPath => ShellModel.NormalizeFolder(Folder.Text);

void OnFolderChanged(object sender, TextChangedEventArgs e) => StartButton.IsEnabled = Directory.Exists(FolderPath);
void OnFolderChanged(object sender, TextChangedEventArgs e)
{
StartButton.IsEnabled = Directory.Exists(FolderPath);
// A worktree needs a git repo; claude --worktree works only at launch, hence here and not on open tabs.
var repo = StartButton.IsEnabled && ShellModel.InGitRepo(FolderPath);
WorktreeSwitch.IsEnabled = repo;
if (!repo) WorktreeSwitch.IsChecked = false;
WorktreeNote.Text = repo
? @"Claude works in a worktree of its own (claude --worktree, under .claude\worktrees), so this folder's checkout is left alone."
: "Only for a folder in a git repository.";
}

void OnBrowse(object sender, RoutedEventArgs e)
{
Expand All @@ -62,7 +72,7 @@ void OnBrowse(object sender, RoutedEventArgs e)
void OnStart(object sender, RoutedEventArgs e)
{
Result = new LaunchOptions(FolderPath, null, Pick(Model), Pick(Effort), Pick(Mode),
string.IsNullOrWhiteSpace(SessionName.Text) ? null : SessionName.Text.Trim());
string.IsNullOrWhiteSpace(SessionName.Text) ? null : SessionName.Text.Trim(), Worktree: WorktreeSwitch.IsChecked == true);
DialogResult = true;
}
}
Loading