diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..3c9689f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,45 @@ +name: Bug report +description: Something in CCWrapper does not work as it should +labels: [bug] +body: + - type: input + id: app-version + attributes: + label: CCWrapper version + description: Shown under the name in the About window. + placeholder: "0.1.0" + validations: + required: true + - type: input + id: windows + attributes: + label: Windows version + - type: input + id: claude-version + attributes: + label: Claude Code version + description: The output of `claude --version`. + - type: textarea + id: what-happened + attributes: + label: What happened? + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. + 2. + 3. + - type: textarea + id: expected + attributes: + label: What did you expect to happen? + - type: textarea + id: logs + attributes: + label: Logs, error text or screenshots + description: "Optional. Remove anything private first: file paths, e-mail addresses, prompts, code and session content." + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0086358 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true diff --git a/CLAUDE.md b/CLAUDE.md index d52eb9c..044edf6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,12 +30,12 @@ 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), `UsageStats` (the usage popup's token counts); `SettingsData`, the Settings window's load-edit-save of `settings.json` and `CLAUDE.md`; `ClaudeJson`, locked updates of `.claude.json` | +| `src/CCWrapper/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 | | `src/CCWrapper.StatusRelay` | The silent relay, two jobs: the statusLine command, and with `--browser ` the stdio MCP server of the Browser tool (native AOT in releases; only the statusLine command is launched by 8.3 short path, as it is a shell command string, so install paths with spaces work; the `--browser` MCP command is the full path, spawned without a shell) | -| `scripts/`, `.github/workflows` | `pack.ps1` builds the Velopack release; `ci.yml` tests, `release.yml` publishes | +| `scripts/`, `.github/` | `pack.ps1` builds the Velopack release; `ci.yml` tests, `release.yml` publishes; `ISSUE_TEMPLATE/bug_report.yml` is the form About's Report a bug prefills (its field ids are the URL parameters) | | Root `*.md`, `THIRD-PARTY-NOTICES.txt` | PRIVACY, TERMS, CODE_SIGNING_POLICY, SECURITY: the policies the About window and SignPath point to | | `tests/CCWrapper.Tests` | xUnit, synthetic fixtures only (never real transcripts) | @@ -57,8 +57,8 @@ dotnet test `.jsonl` and its `\` folder (`SessionCatalog.Delete`), only after a confirm and never while a claude process 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 are - `--mcp-config` and `--append-system-prompt`. `browserTool` off: new tabs get no flags and `BrowserMcp` refuses calls from open ones. + 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. 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/PRIVACY.md b/PRIVACY.md index 03af8a8..5ab32ee 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -41,7 +41,8 @@ If you write to the publisher (see Contact), the publisher handles your message - Every session in the app is the unmodified `claude.exe` that you installed. It runs under your own login and under your own agreement with Anthropic. What it sends to Anthropic is covered by Anthropic's terms and privacy policy, not by this one. - The app never reads `.credentials.json`. The app never calls Anthropic's APIs itself. - To show plan usage, the app runs a hidden `claude "/usage"` through Claude Code. Claude Code contacts Anthropic as it does for any `/usage`, and saves the result in `.claude.json`. -- The Plugins & connectors window runs Claude Code's own `claude plugin` and `claude mcp` commands, only after you click. Those commands may contact plugin marketplaces and servers. +- The Plugins & connectors window runs Claude Code's own `claude plugin` and `claude mcp` commands, only after you click. Those commands may contact plugin marketplaces and servers. Opening a plugin's page there runs `claude plugin details` for it. +- The Account page in Settings and **Report a bug** in About run `claude --version`, which only prints the version. ### 3. Websites you open @@ -49,6 +50,8 @@ If you write to the publisher (see Contact), the publisher handles your message - Claude can use the Browser panel: each session gets it as a local MCP server (`ccwrapper-browser`), unless you turn it off in Settings → CCWrapper (`browserTool` in `app.json`). CCWrapper itself sends nothing new. The pages load only when Claude or you open them, in the panel's profile for the account, which is stored on your computer in `%LOCALAPPDATA%\CCWrapper\WebView2\browser-`, so those sites see your logins there, and what Claude runs in a page (the `evaluate` and `devtools` tools) acts with them. `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. The panel has tabs, all in that one profile; a pop-up opened by a click opens a new tab (at most 20), one a page opens on its own is dropped (except in the few seconds after a click, Claude's clicks included, as in any browser), and a page's `window.close()` closes only a pop-up tab. Claude opens only `http`, `https`, `blob`, `about:blank` and `data` addresses, not local files, and a pop-up to any other address is dropped. Its tools act on the tab in front, can list, open, switch and close tabs (a tab that is not a web page is listed without title or address), and refuse to work on a page that is not a web address (for example a `file:` page you opened in the address bar yourself). All of this holds in every tab. The tool talks to its CCWrapper window through a named pipe on your own computer that only your Windows user can open. What Claude Code does with a page's text, screenshots or script results is covered in section 2, and Claude Code asks your permission per tool. - A link in the terminal opens in the Browser panel if it points to your own computer (`localhost`), and in your default browser otherwise. Only `http` and `https` links open. - Each account has its own browser profile, stored on your computer (see below). +- A few buttons open an address in your default browser, only when you click them, and only `http` and `https`: **View source** on a plugin's page (the plugin's source, as its marketplace gives it), and the account, usage and billing pages of Settings → Account. +- **Report a bug** in About opens GitHub's bug form (`.github/ISSUE_TEMPLATE/bug_report.yml`) in your default browser. The address carries the CCWrapper, Windows and Claude Code versions to prefill the form, so GitHub sees them when the page loads. Nothing else is added, and nothing is submitted until you do it on GitHub, where what you write is public (see above). ### 4. Microsoft Edge WebView2 Runtime @@ -85,7 +88,7 @@ These files stay on your computer. The publisher cannot see them. ### Claude Code's files -- The app **reads** files that Claude Code already writes: each account's `.claude.json` (email, plan, cached usage), `settings.json`, transcripts, session status files, file-history backups, and plugin and skill lists. The usage popup (click the usage meters) adds up token counts from the transcripts, and the `/` list in the compose box reads the installed plugins' commands and skills. Both stay on your computer. It also asks `git` (and `gh`) about the folder you work in: branch, upstream, remote, your git user name and email, and your GitHub account name. It shows them in the top bar and sends them nowhere. +- The app **reads** files that Claude Code already writes: each account's `.claude.json` (email, plan, cached usage), `settings.json`, transcripts, session status files, file-history backups, and plugin and skill lists. The plugin page reads the marketplaces' `known_marketplaces.json` and the `marketplace.json` of the marketplace folder Claude Code cloned. The Account page shows the identity fields of `.claude.json` and its other preferences, read-only, with keys, tokens and similar secrets left out; it never reads `.credentials.json`. The usage popup (click the usage meters) adds up token counts from the transcripts, and the `/` list in the compose box reads the installed plugins' commands and skills. Both stay on your computer. It also asks `git` (and `gh`) about the folder you work in: branch, upstream, remote, your git user name and email, and your GitHub account name. It shows them in the top bar and sends them nowhere. - The app **writes** to Claude Code's files only when you act: - **Save** in the Settings window writes the `settings.json` and `CLAUDE.md` files you changed. - A server's folder switch in Plugins & connectors changes the list of turned-off servers for that folder in `.claude.json`. diff --git a/README.md b/README.md index 9b61e45..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-497%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. @@ -36,6 +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. - `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. @@ -46,18 +47,20 @@ It stays light: a native WPF app (no Electron), Windows' own icon font, a dark s - Click a session to open it: if it already has a tab, that tab comes forward; otherwise it is resumed in its original folder in a new tab. Nothing open is closed. - Right-click a session, here or on its tab, for **Name…** (sends `/rename`), **Color** (sends `/color`) and **Delete…**. Name and Color need the session open in this window. Delete asks first, then removes the session's transcript and its folder for good, so it cannot be resumed; it is refused while any claude process has the session open. - A session open in another terminal gets a read-only tab that says where it is open, e.g. "PowerShell (pid 51708, since 15:07)". **Resume here** offers to close it there (the conversation is saved) and turns the tab into a live one; **Continue here as a copy** forks it instead. -- **About:** version, the update switch and links to the policies (privacy, terms, code signing, security, third-party notices). When an update is downloaded, a chip "Update X ready" in the bottom bar restarts into it. +- **About:** version, the update switch and links to the policies (privacy, terms, code signing, security, third-party notices). **Report a bug** opens GitHub's bug form in your browser, with the CCWrapper, Windows and Claude Code versions filled in (it runs `claude --version` for the last); nothing is sent until you submit the form. When an update is downloaded, a chip "Update X ready" in the bottom bar restarts into it. - **Settings:** a window for the account's `settings.json` and the project folder's shared and local ones, without opening the files. - **General:** model, effort (also per model), output style, language, theme, time format, update channel, commit trailer and claude.ai connectors. - **Permissions:** the default mode and the allow, ask and deny rules. **Hooks** and **Environment** variables can be added, edited and removed; values that look like keys or tokens stay hidden until clicked. - **Interface:** the switches of `/config`: auto-compact, rewind checkpoints, verbose output, turn duration, tips, reduced motion, terminal progress bar, `.gitignore` in the file picker, editor mode and notifications. - Each setting says where its value comes from: set in this file, taken from a broader one, or overridden by a more specific one (*Project overrides this*). + - **Account:** the name, email, organization, plan and IDs from the account's `.claude.json` (never `.credentials.json`), the config folder, the Claude Code version, and the other preferences that no page edits, read-only, with anything that looks like a key or token left out. Buttons open the config folder, and the account, usage and billing pages in your browser. - **CLAUDE.md:** edit the user, project and local `CLAUDE.md` files. **JSON** shows a whole file for anything else. - Changes wait for **Save**, which writes only the files you changed and keeps every key the window doesn't show. Open sessions pick settings up as soon as they are saved. - **CCWrapper:** the app's own settings, saved in `%LOCALAPPDATA%\CCWrapper\app.json` (not a Claude Code file): the compact hint on or off, its threshold and its prompt, and whether Claude may use the Browser panel. - **Plugins & connectors:** a window of its own for everything `/plugin` and `/mcp` do, without touching your open sessions. - - **Plugins:** turn installed plugins on and off; see each one's skills, MCP servers, context cost and notes; update, configure or uninstall it. Plugins synced from your claude.ai account are listed too. - - **Discover and Marketplaces:** search the marketplaces' catalogs and install to the user, project or local scope; add, update and remove marketplaces. + - **Plugins:** turn installed plugins on and off; see each one's skills, MCP servers, context cost and notes; update, configure or uninstall it. Plugins synced from your claude.ai account are listed too. The list scrolls with the mouse wheel. + - **Discover and Marketplaces:** search the marketplaces' catalogs (**Show 60 more** pages through them, from the local copies, with no network) and install to the user, project or local scope; add, update and remove marketplaces. + - **Plugin page:** click a card in Discover for what the marketplace's `marketplace.json` says about the plugin (version, author, license, keywords), its components from a hidden `claude plugin details`, **Install**, and **View source**, which opens the source in your browser (http and https only). - **MCP servers and connectors:** every server with its health (connected, needs sign-in, failed), your own and those of plugins; add or remove your own, and sign in to servers and claude.ai connectors. A switch on each one turns it off for one project folder only, as `/mcp disable` does. - Every change runs Claude Code's own `claude plugin` or `claude mcp` command in the background, shown in the window's status strip. New sessions get the change; **Reload idle sessions** sends `/reload-plugins` to the open ones that are idle. - **New session:** pick a folder, plus optionally a model, effort, permission mode and name. Unchanged fields keep the account's defaults. @@ -83,19 +86,19 @@ It stays light: a native WPF app (no Electron), Windows' own icon font, a dark s - A real multi-line editor with a round send button: Enter sends, Shift+Enter adds a line, and Up recalls earlier prompts. - When Claude Code suggests your next prompt, the empty box shows it greyed too, as the terminal does: Tab takes it to edit, Enter sends it, typing replaces it. - Pasted screenshots become image files in `%LOCALAPPDATA%\CCWrapper\paste`; those older than 7 days are deleted when the app starts. -- The **+** button adds files or folders (as a drop would) and opens the slash commands. Typing `/` (or a `/` word after a space or line break) lists Claude Code's commands, your custom commands and skills, and those of your enabled plugins and claude.ai synced plugins (not MCP prompts), narrowing as you type: Up/Down choose, Enter runs it, Tab completes it without sending (after a space or line break, Enter only completes). Text that starts with a known command shows in blue. A slash command goes in as short pastes (at most 500 characters and one line break each), so Claude Code runs it instead of folding it into `[Pasted text]`. +- The **+** button adds files or folders (as a drop would) and opens the slash commands. Typing `/` (or a `/` word after a space or line break) lists Claude Code's commands, your custom commands and skills, and those of your enabled plugins and claude.ai synced plugins (not MCP prompts), narrowing as you type: Up/Down choose, Enter runs it, Tab completes it without sending (after a space or line break, Enter only completes). A known command shows in blue anywhere in the text. While the caret is at the end of the line, its argument hint (the one Claude Code shows) appears as grey ghost text after it, and Tab writes it in; the hint hides once the typed arguments differ from it and returns when they are empty again. A slash command goes in as short pastes (at most 500 characters and one line break each), so Claude Code runs it instead of folding it into `[Pasted text]`. - Each session keeps its own draft: switching tabs leaves it as it is, and drafts are saved when the app exits, restarts to update, or loses focus once it knows an update is ready to install, and come back when the session is reopened; a draft nobody changed for 14 days, or whose session you delete, is dropped. An update restart started from another window can still lose the drafts of a window that has not noticed the update. - It never types into a permission prompt or a dialog. Those you always answer in the terminal. Confirmations, warnings and errors use the app's dark theme too. **Right panel** -- **Files:** a tree of every file Claude changed this session, with `+/−` lines in green and red, or the size change for binaries. Click a file to see all of it with the changes highlighted. -- **Agents:** this session's subagents, running or finished, with model, effort, context and tokens. Click one to see its live transcript. +- **Files:** a tree of every file Claude changed this session, with `+/−` lines in green and red, or the size change for binaries. Click a file to see all of it with the changes highlighted. A search box filters by name. Right-click a file for **Open with…** (the Windows dialog) and **Show in File Explorer**; on a folder, **Open in File Explorer**. +- **Agents:** a list of this session's subagents, running or finished, each with its title, duration, model, tokens (the last turn's context plus output) and effort, and a search box. Click one to read its log, read-only, in the tab; the back arrow, Esc or the mouse's back button returns to the list. - **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 switches that drive Claude Code's own `/model`, `/effort` and Shift+Tab. +- 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. @@ -172,7 +175,7 @@ A few tips: - On a tab, ● means Claude is working, a pulsing ◎ means it's waiting for you and ○ means it's done. When a session needs you while the window is in the background, the taskbar button flashes. - Running the same session in two places at once isn't possible: open it here with **Resume here**, which can close the other terminal's copy for you. Note that `claude --continue` in a folder reopens that folder's latest session, wherever else it is open. - When a session's process exits, the terminal offers **Resume** or **Close**. -- The model and effort switches send Claude Code's own `/model` and `/effort`, which **save the choice as that account's default**. That's Claude Code's behavior, not the app's. +- The model and effort choosers send Claude Code's own `/model` and `/effort`, which **save the choice as that account's default**. That's Claude Code's behavior, not the app's. - Accounts using Claude Code's fullscreen renderer (`"tui": "fullscreen"`) stay fullscreen. Ctrl+F then searches the visible screen, and "Search full transcript" searches the whole session. ## Privacy and safety @@ -180,8 +183,8 @@ A few tips: CCWrapper is a viewer and a launcher. It doesn't touch your logins. - **It ends a `claude` process only when you confirm it:** Resume here's "Close it there and resume it here?" ends that terminal's `claude`, as closing the terminal would. Nothing else is ever stopped for you. -- **It reads only files Claude Code already writes:** each account's `.claude.json` (email, plan, cached usage), `settings.json`, transcripts (also for the usage popup), session status files, file-history backups, and plugin and skill lists (also for the `/` list). -- **It never reads** `.credentials.json` and never calls Anthropic APIs. Plugin and MCP changes you make in Plugins & connectors are done by Claude Code's own `claude plugin` and `claude mcp` commands. +- **It reads only files Claude Code already writes:** each account's `.claude.json` (email, plan, cached usage), `settings.json`, transcripts (also for the usage popup), session status files, file-history backups, and plugin and skill lists (also for the `/` list), plus the marketplaces' `known_marketplaces.json` and `marketplace.json` (for the plugin page). +- **It never reads** `.credentials.json` and never calls Anthropic APIs. Plugin and MCP changes you make in Plugins & connectors are done by Claude Code's own `claude plugin` and `claude mcp` commands; a plugin page also runs `claude plugin details`. The Account page in Settings and **Report a bug** in About run `claude --version`. - **It edits Claude Code's files only from the Settings window, when you click Save:** the `settings.json` and `CLAUDE.md` files you changed there. A file that changed on disk since the window read it is not overwritten, and the previous version of each saved file is kept in `%LOCALAPPDATA%\CCWrapper\backups`. - **It deletes a session's files only when you pick Delete on its right-click menu and confirm:** the transcript `projects\\.jsonl` and its `\` folder, for good. A session that is open anywhere is refused, and nothing outside those two paths is touched. - **It edits `.claude.json` only when you flip a server's folder switch** in Plugins & connectors, the one change there that no `claude` command makes. Like Claude Code itself, it takes the file's lock, reads the file as it is at that moment, changes that folder's list of servers turned off and writes it back; the previous version goes to the same backups folder. @@ -189,6 +192,7 @@ CCWrapper is a viewer and a launcher. It doesn't touch your logins. - **Otherwise it writes only to** `%LOCALAPPDATA%\CCWrapper`: pasted images (deleted after 7 days), the browser profiles (a Browser cache over 100 MB is cleared at start; cookies stay until you clear them), settings backups, `app.json` (CCWrapper's own settings: your update choice, the compact hint and the browser choice), and the live status data described next. - **The status line:** if an account has no `statusLine` of its own, each session gets a tiny silent relay (`CCWrapper.StatusRelay.exe`) through `--settings`, which feeds the bottom bar. If you have your own status line, it's left alone. - **The Browser tool:** each session also gets that relay as a local MCP server (`ccwrapper-browser`, through `--mcp-config`), and a line in its system prompt (`--append-system-prompt`) to prefer it. The server talks only to its own CCWrapper window, through a local pipe that only your Windows user can open. Pages Claude opens load in the Browser panel's profile for the account (all tabs share it), with your logins, and Claude Code asks permission per tool as it does for any MCP tool. Its scripts and DevTools act with those logins, but DevTools refuses cookie access, downloads, local files, all simulated input (keyboard, mouse, touch and drag), the page history and scripts that persist across pages. Claude opens only http, https, blob, about:blank and data addresses, not local files, and its tools refuse to work on a page that is not a web address. The same holds for every tab. Turn it off in Settings → CCWrapper. +- **Links it opens in your browser, on your click only:** the account, usage and billing pages (Settings → Account), a plugin's source (View source) and GitHub's bug form (Report a bug), whose address carries the three versions. The app sends nothing itself. - **Its only network call is the update check,** and only in an installed copy, only after you said yes on first start; the switch is in **About**. It asks GitHub for the latest release and downloads it; GitHub sees your IP address and the app version, nothing else is sent. The Browser panel loads only what you or Claude open in it. Details in [PRIVACY.md](PRIVACY.md). - **Plan usage is checked with Claude Code's own `/usage`:** a hidden `claude "/usage"` (MCP servers off) runs in the folder of the account's most recent session, and Claude Code saves the result in `.claude.json`. Each check takes a few seconds, adds `/usage` to that folder's prompt history (Up arrow), and runs your startup hooks like any `claude` start, so automatic checks are skipped when the last one is under 10 minutes old. A check that can't finish in 30 seconds (not logged in, a trust question) is stopped and the last numbers stay. - Every session is the unmodified `claude.exe`, run under your own login. @@ -196,6 +200,7 @@ CCWrapper is a viewer and a launcher. It doesn't touch your logins. ## Known limits - **Undocumented files.** Session status files, transcript records and file-history backups are internal to Claude Code and can change with any update. If they do, the affected field shows "—" instead of the app crashing. +- **Model names and the Ultracode state.** The model list with its versions is kept by hand in the app, so a new model or a new version shows up after an update of CCWrapper. Ultracode's state is read from Claude Code's footer when it shows it, else it is the last value sent from the popup, and it is forgotten on restart. - **Files panel** only sees edits made with Claude's Edit and Write tools, not files changed by shell commands. - **Plan usage between checks** is as fresh as the last hidden `/usage`. On Pro and Max the live status line also updates it after each reply in a session open in the app; Team accounts get no live updates. - **Memory:** each open session is a full `claude.exe` (about 200–300 MB), so close the ones you don't need. @@ -223,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 497 tests on synthetic fixtures. +- xUnit, with 602 tests on synthetic fixtures. ## Development @@ -242,7 +247,7 @@ src/CCWrapper/ WPF app (Theme.xaml: colors and styles, App.xaml: Flu src/CCWrapper.StatusRelay/ silent relay: the statusLine → %LOCALAPPDATA%\CCWrapper\live, and the Browser tool's MCP server (--browser) tests/CCWrapper.Tests/ xUnit tests (one builds every view with the theme loaded) docs/ the screenshot -scripts/pack.ps1 builds the Velopack installer (CI: .github/workflows) +scripts/pack.ps1 builds the Velopack installer (CI: .github/workflows; the bug form: .github/ISSUE_TEMPLATE) ``` **Contributing.** Issues and pull requests are welcome; `main` only takes pull requests. diff --git a/src/CCWrapper/Core/LiveSession.cs b/src/CCWrapper/Core/LiveSession.cs index 947bff9..079d6c6 100644 --- a/src/CCWrapper/Core/LiveSession.cs +++ b/src/CCWrapper/Core/LiveSession.cs @@ -24,6 +24,14 @@ 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); } + + /// Launched as a new session with --worktree: Claude Code moved it into the worktree, so its folder + /// (and transcript project) is the worktree root. A session that only runs inside a worktree keeps its launch folder. + public bool WorktreeLaunch { get; set; } + /// 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/Data/AccountReader.cs b/src/CCWrapper/Data/AccountReader.cs index 68d6da6..6693a87 100644 --- a/src/CCWrapper/Data/AccountReader.cs +++ b/src/CCWrapper/Data/AccountReader.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.IO; using System.Text.Json; +using System.Text.RegularExpressions; using CCWrapper.Core; namespace CCWrapper.Data; @@ -18,8 +19,86 @@ public sealed record AccountInfo( DateTimeOffset? UsageFetchedAt, UsageWindow? FiveHour, UsageWindow? SevenDay, string? Model, string? Effort, string? DefaultMode); +/// One read-only row of the Account page's "other configuration": is the file it came from. +public sealed record ConfigRow(string Source, string Key, string Value); + +/// What the Account page shows beyond . Every field is optional. +public sealed record AccountFacts(string? AccountUuid, string? OrganizationUuid, string? InstallMethod, string? AutoUpdates, + IReadOnlyList Other); + +/// The browser pages of an account's own settings, chosen from its billing and organization type. One place for every URL. +public static class AccountLinks +{ + const string Claude = "https://claude.ai/settings/", ConsoleHome = "https://platform.claude.com/"; + + /// Manage account, Usage and Billing as (label, https url). The Claude Console for API/console accounts, claude.ai + /// for subscriptions and for any account of unknown kind (the fallback). Billing only when an account is signed in. + public static List<(string Label, string Url)> For(AccountInfo info) + { + // organizationType of a subscription is "claude_pro", "claude_max", "claude_team", "claude_enterprise". + var console = info.OrganizationType is { Length: > 0 } t ? !t.StartsWith("claude", StringComparison.OrdinalIgnoreCase) + : info.BillingType is { } b && (b.Contains("api", StringComparison.OrdinalIgnoreCase) || b.Contains("prepaid", StringComparison.OrdinalIgnoreCase)); + // Console: /usage and /settings/billing are in Anthropic's help center (support.claude.com articles 9534590 and 8977456). It has + // no documented profile page, so "Manage account" opens the Console home rather than a guessed path. + var links = console + ? new List<(string, string)> { ("Manage account", ConsoleHome), ("Usage", ConsoleHome + "usage") } + : new List<(string, string)> { ("Manage account", Claude + "account"), ("Usage", Claude + "usage") }; + if (info.Email is not null || info.BillingType is not null) links.Add(("Billing", (console ? ConsoleHome + "settings/" : Claude) + "billing")); + return links; + } +} + public static class AccountReader { + // Keys another Settings page edits (settings.json, or the leftovers of older versions in .claude.json), or that are noise. + static readonly HashSet Handled = new(StringComparer.Ordinal) + { + "model", "modelSettings", "effortLevel", "outputStyle", "language", "theme", "timeFormat", "autoUpdatesChannel", "attribution", + "disableClaudeAiConnectors", "permissions", "hooks", "env", "autoCompactEnabled", "fileCheckpointingEnabled", "verbose", + "showTurnDuration", "spinnerTipsEnabled", "prefersReducedMotion", "terminalProgressBarEnabled", "respectGitignore", "editorMode", + "preferredNotifChannel", "installMethod", "autoUpdates", "userID", "anonymousId", + }; + + // Anything secret-like stays out, whatever the file or value. + static readonly Regex Secret = new("key|token|secret|auth|credential|password", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + /// The scalar preferences of .claude.json and the settings.json keys no other Settings page edits, secret-looking + /// keys, caches and everything nested (projects, history, oauthAccount) left out. settings.json objects and arrays show as a size. + public static List OtherConfig(JsonElement global, JsonElement settings) => + Rows(".claude.json", global, false).Concat(Rows("settings.json", settings, true)).ToList(); + + static IEnumerable Rows(string source, JsonElement root, bool summarize) + { + if (root.ValueKind != JsonValueKind.Object) yield break; + foreach (var p in root.EnumerateObject().OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)) + { + if (Handled.Contains(p.Name) || Secret.IsMatch(p.Name) || p.Name.StartsWith("cached", StringComparison.Ordinal)) continue; + var v = p.Value; + var text = v.ValueKind switch + { + JsonValueKind.String => v.GetString() is { Length: > 120 } l ? l[..120] + "…" : v.GetString(), + JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.GetRawText(), + JsonValueKind.Object when summarize => $"{{{v.EnumerateObject().Count()} keys}}", + JsonValueKind.Array when summarize => $"[{v.GetArrayLength()} items]", + _ => null, + }; + if (text is not null) yield return new(source, p.Name, text); + } + } + + /// The Account page's facts; off the UI thread like . + public static AccountFacts ReadFacts(Account account) + { + using var global = JsonUtil.Load(account.GlobalConfigPath); + using var settings = JsonUtil.Load(account.Paths.Settings); + var g = global?.RootElement ?? default; + var oauth = g.Prop("oauthAccount"); + var auto = g.Prop("autoUpdates"); + return new(oauth.Prop("accountUuid").Str(), oauth.Prop("organizationUuid").Str(), g.Prop("installMethod").Str(), + auto.ValueKind is JsonValueKind.True or JsonValueKind.False ? auto.GetRawText() : null, + OtherConfig(g, settings?.RootElement ?? default)); + } + /// Reads .claude.json and settings.json; a missing or unparsable file just leaves its fields null. /// Blocks up to ~120 ms if a file is being rewritten, so call it off the UI thread. public static AccountInfo Read(Account account) 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/Data/PluginData.cs b/src/CCWrapper/Data/PluginData.cs index d858ff4..2381d09 100644 --- a/src/CCWrapper/Data/PluginData.cs +++ b/src/CCWrapper/Data/PluginData.cs @@ -41,6 +41,11 @@ public sealed record MarketplaceInfo(string Name, string Source, string? Where) }; } +/// What a marketplace's own manifest says about one plugin; every field optional. is an +/// http(s) address of the plugin's source, or null when none can be derived. +public sealed record PluginManifest(string? Version, string? Author, string? Homepage, string? Repository, string? License, + IReadOnlyList Keywords, string? Category, string? SourceUrl); + /// Skills, Agents, Hooks, MCP servers, LSP servers… public sealed record PluginComponent(string Kind, int Count, string Names); @@ -105,6 +110,71 @@ public static (List Shown, int Matches) Search(IEnumerableWhere is cloned and the web address of its repo (null when it has none), from + /// plugins\known_marketplaces.json. + public static (string? Folder, string? RepoUrl) ParseKnownMarketplace(JsonElement root, string marketplace) + { + var m = root.Prop(marketplace); + return (m.Prop("installLocation").Str(), RepoUrl(m.Prop("source"))); + } + + /// The manifest entry of in the marketplace's marketplace.json. + /// is the marketplace's own repo, which a relative plugin source lives in. + public static PluginManifest ParseManifest(JsonElement marketplaceJson, string plugin, string? repoUrl) + { + var e = marketplaceJson.Prop("plugins") is { ValueKind: JsonValueKind.Array } list + ? list.EnumerateArray().FirstOrDefault(p => p.Prop("name").Str() == plugin) : default; + var author = e.Prop("author"); + var repository = e.Prop("repository"); + return new(e.Prop("version").Str(), author.Str() ?? author.Prop("name").Str(), e.Prop("homepage").Str(), + repository.Str() ?? repository.Prop("url").Str(), e.Prop("license").Str(), + Strings(e.Prop("keywords")).Concat(Strings(e.Prop("tags"))).Distinct().ToList(), e.Prop("category").Str(), + SourceUrl(e.Prop("source"), repoUrl)); + } + + /// The plugin's marketplace.json entry read from its cloned marketplace folder; all-empty when the file is + /// missing or unreadable. + public static PluginManifest ReadManifest(string? folder, string plugin, string? repoUrl) + { + try + { + if (folder is not null) + { + using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(folder, ".claude-plugin", "marketplace.json"))); + return ParseManifest(doc.RootElement, plugin, repoUrl); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + // a marketplace without a readable manifest just shows fewer fields + } + return ParseManifest(default, plugin, repoUrl); + } + + /// Web address of a plugin's source: a relative path inside a GitHub marketplace, a github repo, or a git/url source + /// with its optional path and ref. Only http and https come out. + public static string? SourceUrl(JsonElement source, string? marketplaceRepoUrl) + { + if (source.Str() is { } rel) // "./plugins/x": inside the marketplace's repo + return marketplaceRepoUrl is null ? null : InRepo(marketplaceRepoUrl, rel.Trim().TrimStart('.', '/'), null); + return RepoUrl(source) is { } repo ? InRepo(repo, source.Prop("path").Str()?.Trim().TrimStart('.', '/'), source.Prop("ref").Str() ?? source.Prop("sha").Str()) : null; + } + + /// The repo behind a {"source": "github", "repo"} or git/url source, as an http(s) address. + static string? RepoUrl(JsonElement source) + { + var url = source.Prop("repo").Str() is { Length: > 0 } repo && !repo.Contains(':') ? "https://github.com/" + repo : source.Prop("url").Str(); + if (url is null) return null; + var scp = Regex.Match(url, @"^git@(?[^:]+):(?.+)$"); // git@github.com:owner/repo.git + if (scp.Success) url = $"https://{scp.Groups["host"].Value}/{scp.Groups["path"].Value}"; + if (url.EndsWith(".git")) url = url[..^4]; + return Uri.TryCreate(url, UriKind.Absolute, out var u) && u.Scheme is "http" or "https" ? u.AbsoluteUri.TrimEnd('/') : null; + } + + // shortcut: a /tree/ link only for github.com; other hosts get the repo root. + static string InRepo(string repo, string? path, string? gitRef) => + string.IsNullOrEmpty(path) || !repo.StartsWith("https://github.com/") ? repo : $"{repo}/tree/{gitRef ?? "HEAD"}/{path.Replace('\\', '/')}"; + /// claude plugin marketplace list --json, ordered by name. public static List ParseMarketplaces(JsonElement root) => root.ValueKind == JsonValueKind.Array 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/Data/SlashCommands.cs b/src/CCWrapper/Data/SlashCommands.cs index 1b3663f..3f38bdb 100644 --- a/src/CCWrapper/Data/SlashCommands.cs +++ b/src/CCWrapper/Data/SlashCommands.cs @@ -6,7 +6,8 @@ namespace CCWrapper.Data; /// built-in, user, project or a skill's "user skill" / "project skill". -public sealed record SlashCommand(string Name, string Description, string Source); +/// What the command takes, as Claude Code greys it after "/name " ("argument-hint:" of the frontmatter). +public sealed record SlashCommand(string Name, string Description, string Source, string? ArgumentHint = null); /// The slash commands the compose box offers: Claude Code's built-ins, custom commands (commands\**\*.md) and /// skills (skills\*\SKILL.md), each from the account's config folder and the project's .claude folder, and those @@ -14,21 +15,21 @@ public sealed record SlashCommand(string Name, string Description, string Source /// Code writes lists them. public static class SlashCommands { - static SlashCommand B(string name, string description) => new(name, description, "built-in"); + static SlashCommand B(string name, string description, string? hint = null) => new(name, description, "built-in", hint); - // shortcut: hand-kept, Claude Code exposes no list to read; upgrade if it does. + // shortcut: hand-kept, argument hints included, Claude Code exposes no list to read; upgrade if it does. static readonly SlashCommand[] BuiltIn = [ - B("add-dir", "Add a working directory"), B("agents", "Manage subagents"), B("clear", "Clear the conversation"), - B("color", "Set the session color"), B("compact", "Summarize the conversation to free context"), B("config", "Open settings"), + B("add-dir", "Add a working directory", ""), B("agents", "Manage subagents"), B("clear", "Clear the conversation"), + B("color", "Set the session color", "[color]"), B("compact", "Summarize the conversation to free context", "[instructions]"), B("config", "Open settings"), B("context", "Show context usage"), B("cost", "Show token usage and cost"), B("doctor", "Check the Claude Code installation"), - B("effort", "Set the effort level"), B("exit", "Exit Claude Code"), B("export", "Export the conversation"), + B("effort", "Set the effort level", "[low|medium|high|xhigh|max]"), B("exit", "Exit Claude Code"), B("export", "Export the conversation", "[filename]"), B("help", "Show help and commands"), B("hooks", "Manage hooks"), B("ide", "Manage IDE integration"), B("init", "Create a CLAUDE.md for this project"), B("login", "Sign in"), B("logout", "Sign out"), - B("mcp", "Manage MCP servers"), B("memory", "Edit memory files"), B("model", "Choose the model"), - B("permissions", "Manage tool permissions"), B("plugin", "Manage plugins"), B("pr-comments", "Show comments of a pull request"), - B("release-notes", "Show the release notes"), B("rename", "Rename the session"), B("resume", "Resume a conversation"), - B("review", "Review a pull request"), B("rewind", "Rewind the conversation or code"), B("security-review", "Review pending changes for security"), + B("mcp", "Manage MCP servers"), B("memory", "Edit memory files"), B("model", "Choose the model", "[model]"), + B("permissions", "Manage tool permissions"), B("plugin", "Manage plugins"), B("pr-comments", "Show comments of a pull request", "[pr number]"), + B("release-notes", "Show the release notes"), B("rename", "Rename the session", "[name]"), B("resume", "Resume a conversation", "[session id or search]"), + B("review", "Review a pull request", "[pr number]"), B("rewind", "Rewind the conversation or code"), B("security-review", "Review pending changes for security"), B("skills", "List skills"), B("status", "Show version, model and account"), B("statusline", "Set up the status line"), B("terminal-setup", "Set up terminal key bindings"), B("theme", "Change the theme"), B("todos", "Show the to-do list"), B("usage", "Show plan usage"), B("vim", "Toggle vim editing mode"), @@ -63,8 +64,26 @@ public static List Filter(IEnumerable all, string qu return [.. starts, .. rows.Where(c => !starts.Contains(c) && c.Name.Contains(query, StringComparison.OrdinalIgnoreCase))]; } - static IEnumerable Skills(string dir, string source, string prefix) => - PluginData.ReadSkills(dir, source).Where(s => s.UserInvocable).Select(s => new SlashCommand(prefix + s.Name, One(s.Description), s.Source)); + // As PluginData.ReadSkills, which keeps no argument hint. + static List Skills(string dir, string source, string prefix) + { + var rows = new List(); + try + { + if (!Directory.Exists(dir)) return rows; + foreach (var skillDir in Directory.EnumerateDirectories(dir)) + { + var file = Path.Combine(skillDir, "SKILL.md"); + if (!File.Exists(file)) continue; + var text = File.ReadAllText(file); + if (!PluginData.UserInvocable(text)) continue; + var (name, description) = PluginData.Frontmatter(text); + rows.Add(new(prefix + (name ?? Path.GetFileName(skillDir)), One(description ?? ""), source, Hint(text))); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } // an unreadable skill folder just lists fewer skills + return rows; + } // The enabled plugins of installed_plugins.json: "enabledPlugins" of settings.json, project, then local settings, later wins. // Of several installs of one plugin the project one for this cwd wins, then the user one. @@ -166,7 +185,7 @@ static List Commands(string dir, string source, string prefix = "" var text = File.ReadAllText(file); if (!PluginData.UserInvocable(text)) continue; var name = prefix + Path.ChangeExtension(Path.GetRelativePath(dir, file), null).Replace('\\', ':').Replace('/', ':'); - rows.Add(new(name, Describe(text), source)); + rows.Add(new(name, Describe(text), source, Hint(text))); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } // this file is just missing } @@ -184,5 +203,16 @@ static string Describe(string markdown) return One(lines.Skip(body).Select(l => l.Trim().TrimStart('#')).FirstOrDefault(l => l.Trim().Length > 0) ?? ""); } + // "argument-hint:" of the leading --- block, quotes dropped; "[file]" is kept as written (YAML would read a list). + static string? Hint(string markdown) + { + var lines = markdown.ReplaceLineEndings("\n").Split('\n'); + if (lines[0].Trim() != "---") return null; + for (var i = 1; i < lines.Length && lines[i].Trim() != "---"; i++) + if (Regex.Match(lines[i], @"^argument-hint:\s*(.*)$") is { Success: true } m) + return One(m.Groups[1].Value.Trim().Trim('"', '\'')) is { Length: > 0 } hint ? hint : null; + return null; + } + static string One(string text) => string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); } 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/Terminal/ClaudeCli.cs b/src/CCWrapper/Terminal/ClaudeCli.cs index 2ee246c..d537c62 100644 --- a/src/CCWrapper/Terminal/ClaudeCli.cs +++ b/src/CCWrapper/Terminal/ClaudeCli.cs @@ -69,6 +69,14 @@ public static async Task RunAsync(Account account, string? cwd, IRead return new CliResult(process.ExitCode, Plain(await stdout), Plain(await stderr)); } + /// The first line of claude --version, or null when claude is missing, fails or takes over 10 s. Starts the + /// process off the calling (UI) thread. The version does not depend on the account. + public static async Task VersionAsync(Account account) + { + try { return await Task.Run(() => RunAsync(account, null, ["--version"], TimeSpan.FromSeconds(10))) is { Ok: true, Line.Length: > 0 } r ? r.Line : null; } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException) { return null; } // no claude.exe + } + static string Plain(string text) => Regex.Replace(text, @"\x1b\[[0-9;?]*[A-Za-z]", "").Trim(); /// How a command reads in the status strip: claude plugin disable x --scope user, arguments with spaces quoted. 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/Terminal/TerminalHost.xaml.cs b/src/CCWrapper/Terminal/TerminalHost.xaml.cs index d614def..e88cdc5 100644 --- a/src/CCWrapper/Terminal/TerminalHost.xaml.cs +++ b/src/CCWrapper/Terminal/TerminalHost.xaml.cs @@ -62,10 +62,21 @@ await StartAsync(tab, ClaudeLaunch.FindClaude(), args, options.Cwd, } /// Relaunches an exited session in its terminal with --resume <SessionId> (the overlay's Resume). - public Task ResumeAsync(LiveSession session) => - _tabs.TryGetValue(session.TabId, out var tab) && tab.Pty is null && tab.Size is null && tab.Options is { } o && session.SessionId is { } id - ? OpenAsync(session, o with { ResumeId = id, Name = null, Fork = false }) // a copy resumes as itself: its id was adopted - : Task.CompletedTask; + public Task ResumeAsync(LiveSession session) + { + if (!(_tabs.TryGetValue(session.TabId, out var tab) && tab.Pty is null && tab.Size is null && tab.Options is { } o && session.SessionId is { } id)) + return Task.CompletedTask; + 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) + 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; + } + + /// Resume in the folder the session runs in now (a --worktree tab: the worktree root, where its transcript is), else the launch folder. + internal static LaunchOptions ResumeOptions(LaunchOptions launched, string? cwd, string id) => + launched with { Cwd = cwd ?? launched.Cwd, ResumeId = id, Name = null, Fork = false }; /// Brings the session's terminal forward; the others keep running hidden. public void Show(LiveSession session) diff --git a/src/CCWrapper/Theme.xaml b/src/CCWrapper/Theme.xaml index 1182edd..4b2567f 100644 --- a/src/CCWrapper/Theme.xaml +++ b/src/CCWrapper/Theme.xaml @@ -10,6 +10,7 @@ + @@ -18,7 +19,7 @@ - + @@ -532,4 +533,11 @@ + + diff --git a/src/CCWrapper/Views/AboutWindow.xaml b/src/CCWrapper/Views/AboutWindow.xaml index 9c41920..fd04c50 100644 --- a/src/CCWrapper/Views/AboutWindow.xaml +++ b/src/CCWrapper/Views/AboutWindow.xaml @@ -25,5 +25,15 @@ + + + diff --git a/src/CCWrapper/Views/AboutWindow.xaml.cs b/src/CCWrapper/Views/AboutWindow.xaml.cs index a562e5f..1c155a1 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,31 @@ 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; + } + + 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 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; } + } + finally { BugButton.IsEnabled = true; } + } } 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..75ac248 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,120 @@ 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) { 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))); + /// 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 trimmed search text in the description or agent type; blank text matches all. + public static bool Matches(SubagentInfo info, string query) + { + var q = query.Trim(); + return q.Length == 0 || (info.Description?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false) + || (info.AgentType?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false); + } public static string Tokens(long? n) => n switch { @@ -69,6 +145,14 @@ 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}$", ""); } 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 @@ - - - + + + + + +