diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 3c9689f..fc5fd56 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -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
diff --git a/CLAUDE.md b/CLAUDE.md
index 044edf6..ad539cb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -58,7 +58,7 @@ dotnet test
or tab has the session; it refuses any path outside the account's `projects\
\.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,
diff --git a/README.md b/README.md
index a63db04..2576c5e 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@



-
+
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.
@@ -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.
@@ -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.
@@ -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
diff --git a/src/CCWrapper/CCWrapper.csproj b/src/CCWrapper/CCWrapper.csproj
index e6a2996..1fae31e 100644
--- a/src/CCWrapper/CCWrapper.csproj
+++ b/src/CCWrapper/CCWrapper.csproj
@@ -6,7 +6,7 @@
enableenabletrue
- 0.1.0
+ 1.2.0CCWrapper.Program
diff --git a/src/CCWrapper/Data/TranscriptReader.cs b/src/CCWrapper/Data/TranscriptReader.cs
index 49f7a1d..cc44aaf 100644
--- a/src/CCWrapper/Data/TranscriptReader.cs
+++ b/src/CCWrapper/Data/TranscriptReader.cs
@@ -179,6 +179,23 @@ static string ToolSummary(JsonElement toolUse)
return $"{name}: {(line.Length > 120 ? line[..120] + "…" : line)}";
}
+ /// What local commands (/effort, /color…) printed, recorded from byte on, oldest first. Claude Code
+ /// wraps it in <local-command-stdout>, as a "local_command" system record's content or as a user message. A missing file gives none.
+ public static List LocalCommandOutputs(string path, long from)
+ {
+ const string Open = "", Close = "";
+ var outputs = new List();
+ 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;
+ }
+
/// 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.
internal static class Get
diff --git a/src/CCWrapper/Views/BottomBar.xaml.cs b/src/CCWrapper/Views/BottomBar.xaml.cs
index 0acf686..3f63119 100644
--- a/src/CCWrapper/Views/BottomBar.xaml.cs
+++ b/src/CCWrapper/Views/BottomBar.xaml.cs
@@ -1,4 +1,5 @@
using System.Globalization;
+using System.IO;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Automation;
@@ -428,8 +429,12 @@ 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;
@@ -437,6 +442,19 @@ void Show(bool v)
ChoiceBody.Children.Add(row);
}
+ /// Claude Code's refusal of the /effort ultracode just sent, from the transcript written after byte ;
+ /// 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).
+ static async Task 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;
+ }
+
/// The mode in the terminal's footer (see ), also kept for display; null if
/// there is no terminal or no footer.
async Task ReadScreenModeAsync(LiveSession s)
@@ -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;
+ /// 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 …").
+ 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;
+
/// 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".
diff --git a/src/CCWrapper/Views/ComposeBox.xaml.cs b/src/CCWrapper/Views/ComposeBox.xaml.cs
index 4339e47..37f7550 100644
--- a/src/CCWrapper/Views/ComposeBox.xaml.cs
+++ b/src/CCWrapper/Views/ComposeBox.xaml.cs
@@ -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";
+ /// Claude Code itself refused a command that went in ( is its reply): shown like a refused submit.
+ public static void Refused(LiveSession s, string reason) => Changed?.Invoke(s, reason);
+
/// The bottom bar's Shift+Tab loop runs: says so until it ends.
public static void SetSwitching(LiveSession s, bool on)
{
diff --git a/src/CCWrapper/Views/MainWindow.xaml b/src/CCWrapper/Views/MainWindow.xaml
index e072f73..55d5489 100644
--- a/src/CCWrapper/Views/MainWindow.xaml
+++ b/src/CCWrapper/Views/MainWindow.xaml
@@ -21,10 +21,6 @@
-
-
-
-
diff --git a/src/CCWrapper/Views/MainWindow.xaml.cs b/src/CCWrapper/Views/MainWindow.xaml.cs
index b06838f..5d3bc75 100644
--- a/src/CCWrapper/Views/MainWindow.xaml.cs
+++ b/src/CCWrapper/Views/MainWindow.xaml.cs
@@ -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);
}
/// Shows the live read-only transcript of a session open in another claude process, as the read-only card.
@@ -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.";
}
/// The letter on a color circle, the taskbar overlay that tells the windows of different accounts apart.
diff --git a/src/CCWrapper/Views/NewSessionDialog.xaml b/src/CCWrapper/Views/NewSessionDialog.xaml
index 8b5eda1..8af02bf 100644
--- a/src/CCWrapper/Views/NewSessionDialog.xaml
+++ b/src/CCWrapper/Views/NewSessionDialog.xaml
@@ -33,6 +33,15 @@
+
+
+
+
+
+
+
+
diff --git a/src/CCWrapper/Views/NewSessionDialog.xaml.cs b/src/CCWrapper/Views/NewSessionDialog.xaml.cs
index f6fc8f3..183337e 100644
--- a/src/CCWrapper/Views/NewSessionDialog.xaml.cs
+++ b/src/CCWrapper/Views/NewSessionDialog.xaml.cs
@@ -7,7 +7,7 @@
namespace CCWrapper.Views;
-/// Folder, model, effort, permission mode and name of a new session. A combo left on "(account default…)"
+/// 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.
public partial class NewSessionDialog : Window
{
@@ -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)
{
@@ -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;
}
}
diff --git a/tests/CCWrapper.Tests/Detail/TranscriptReaderTests.cs b/tests/CCWrapper.Tests/Detail/TranscriptReaderTests.cs
index 84fdf03..50ea5b7 100644
--- a/tests/CCWrapper.Tests/Detail/TranscriptReaderTests.cs
+++ b/tests/CCWrapper.Tests/Detail/TranscriptReaderTests.cs
@@ -18,6 +18,21 @@ string Copy()
return path;
}
+ [Fact]
+ public void Local_command_outputs_after_an_offset_in_both_record_shapes()
+ {
+ var path = Path.Combine(_dir, "cmd.jsonl");
+ const string Old = """{"type":"system","subtype":"local_command","content":"old"}""" + "\n";
+ File.WriteAllText(path, Old
+ + """{"type":"system","subtype":"local_command","content":"/effort"}""" + "\n"
+ + """{"type":"system","subtype":"local_command","content":"Ultracode off. Effort stays high."}""" + "\n"
+ + """{"type":"user","message":{"role":"user","content":"Session color set to: green"}}""" + "\n"
+ + """{"type":"user","message":{"role":"user","content":"plain prompt"}}""" + "\n");
+
+ Assert.Equal(["Ultracode off. Effort stays high.", "Session color set to: green"], TranscriptReader.LocalCommandOutputs(path, Old.Length));
+ Assert.Empty(TranscriptReader.LocalCommandOutputs(Path.Combine(_dir, "missing.jsonl"), 0));
+ }
+
[Fact]
public void Poll_reads_state_from_the_records_and_skips_malformed_and_unknown_lines()
{
diff --git a/tests/CCWrapper.Tests/Shell/BarChoiceTests.cs b/tests/CCWrapper.Tests/Shell/BarChoiceTests.cs
index bcbba25..b3a0e5d 100644
--- a/tests/CCWrapper.Tests/Shell/BarChoiceTests.cs
+++ b/tests/CCWrapper.Tests/Shell/BarChoiceTests.cs
@@ -49,6 +49,20 @@ public void Model_rows_are_the_catalog_else_the_bare_aliases()
public void Ultracode_is_read_from_the_footer_rows(string footer, bool? on) =>
Assert.Equal(on, BarFormat.UltracodeFromScreen("Claude Code\n\n❯ \n──────\n" + footer + "\n"));
+ [Theory]
+ [InlineData("Ultracode on (this session only): longer, deeper work. Effort stays xhigh.", true, null)]
+ [InlineData("Ultracode off. Effort stays xhigh.", true, null)]
+ [InlineData("Ultracode needs dynamic workflows enabled (see /config). Valid options are: low, medium, high, xhigh, max, auto", false,
+ "Ultracode needs dynamic workflows enabled (see /config).")]
+ [InlineData("Ultracode isn't available on claude-haiku-5-5. Valid options are: low, medium", false, "Ultracode isn't available on claude-haiku-5-5.")]
+ public void Ultracode_reply_tells_taken_from_refused(string output, bool taken, string? refusal) =>
+ Assert.Equal((taken, refusal), BarFormat.UltracodeReply(output));
+
+ [Theory]
+ [InlineData("Effort level set to high · Ultracode off")]
+ [InlineData("Session color set to: green")]
+ public void Other_command_output_is_no_ultracode_reply(string output) => Assert.Null(BarFormat.UltracodeReply(output));
+
[Fact]
public void Ultracode_ignores_older_rows_and_missing_screens()
{