Greenfield Tauri 2 desktop application: visual manager for Claude Code agents, commands, skills, plans, plugins, MCP servers, and sessions. Reads/writes ~/.claude/. Conversational use happens through an embedded terminal running the claude CLI.
Stack: Tauri 2 shell · Vite + Vue 3 + vue-router + Tailwind frontend · Rust workspace backend (core / pty / watcher / claude_cli / app) · ts-rs type sharing · invoke() + listen() IPC (no HTTP/WS).
- Overview
- Architecture
- Data Model
- IPC Contract
- Pages & Navigation
- Terminal Subsystem
- File Watcher & Context Monitor
- Distribution & Runtime
- Extensibility
- Implementation Roadmap
claude-code-gui is a single-binary desktop app that wraps the ~/.claude/ directory in a visual editor and runtime. Built on Tauri 2 with a Vue 3 SPA frontend and a pure Rust backend.
It exposes:
- Authoring UI for agents, slash commands, skills, plans, output styles, MCP servers
- Marketplace for discovering and installing plugins from configured sources
- Embedded terminal that runs the
claudeCLI with optional agent system prompt preload - Session browser for past Claude Code projects and conversations under
~/.claude/projects/ - Live monitoring of token usage, cost, file changes, and tool calls during a CLI session
- Developers using Claude Code who want a visual editor for a growing agent/skill collection
- Power users running multiple Claude Code sessions wanting a single pane to inspect them
- Anyone who'd rather click than hand-edit YAML frontmatter
- Single binary distribution. One signed installer per platform (
.dmg/.msi/.AppImage), < 20 MB compressed. - Native performance. OS WebView (WKWebView / WebView2 / WebKitGTK). No Node runtime. No Chromium.
- Zero data migration. Reads and writes the existing
~/.claude/layout maintained by the Claude CLI. - Terminal-only chat. No SDK integration for conversational use. The CLI is the canonical entry point.
- Pure Rust backend. Every server-side responsibility is a Tauri command, a Tokio task, or a spawned subprocess (
claude,git). - Type-safe IPC. Rust-defined request/response shapes are exported as TypeScript via
ts-rs. No drift.
- Web deployment. Desktop only.
- Multi-user or server mode.
- Mobile.
- Hosting Claude or any LLM. Requires
claudeCLI on the host. - Authentication. Single local user, single
~/.claude/directory.
| Term | Meaning |
|---|---|
| Agent | Markdown file under ~/.claude/agents/ with YAML frontmatter (name, model, color, etc.) and an instructions body. Acts as a system prompt template. |
| Command | Markdown file under ~/.claude/commands/ invoked as /name in chat. Frontmatter declares args and allowed tools. |
| Skill | Self-contained directory ~/.claude/skills/<name>/SKILL.md. Loaded on-demand by the CLI when triggers match. |
| Plugin | Marketplace bundle installed under ~/.claude/plugins/. |
| MCP server | External tool server speaking the Model Context Protocol. Registered in .mcp.json. |
| Plan | Plain markdown design doc under ~/.claude/plans/. |
| Output style | Style sheet for assistant output — global or project-scoped markdown. |
| Session | A single Claude conversation. JSONL under ~/.claude/projects/<encoded>/<sessionId>.jsonl. |
| Project | A working directory whose path Claude CLI has been opened in. Surfaces under ~/.claude/projects/ with / encoded as -. |
┌──────────────────────────────────────────────────────────────┐
│ Tauri 2 Application │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ WebView (WKWebView / WebView2 / WebKitGTK) │ │
│ │ │ │
│ │ Vue 3 SPA (Vite) │ │
│ │ ├─ Routes: /agents /commands /skills /plans … │ │
│ │ ├─ Composables: useAgents, useTerminal, … │ │
│ │ ├─ Components: PageHeader, ChatTerminal, … │ │
│ │ └─ Tauri JS API: invoke(), event::listen() │ │
│ └────────────────────────┬─────────────────────────────┘ │
│ │ IPC │
│ ┌────────────────────────▼─────────────────────────────┐ │
│ │ Rust core (Tauri main process) │ │
│ │ │ │
│ │ Commands ────────────────────────────────────┐ │ │
│ │ │ agents::* commands::* skills::* plans::* │ │ │
│ │ │ output_styles::* mcp::* plugins::* │ │ │
│ │ │ marketplace::* projects::* sessions::* │ │ │
│ │ │ settings::* config::* terminal::* │ │ │
│ │ │ claude_cli::improve_instructions │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Long-running Tokio tasks │ │
│ │ ├─ PTY readers (per session) → events │ │
│ │ ├─ File watcher (notify) → events │ │
│ │ └─ claude -p subprocess (one-shot rewrites) │ │
│ │ │ │
│ │ Stateless utilities │ │
│ │ ├─ frontmatter parser (serde_yaml) │ │
│ │ ├─ relationship extractor │ │
│ │ ├─ git ops (git2) │ │
│ │ └─ marketplace fetcher (reqwest + git2) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
└───────────────────────────┼──────────────────────────────────┘
│
▼
~/.claude/ (filesystem)
~/.claude/projects/
claude, git (PATH)
claude-code-gui/
├── frontend/ # Vue 3 + Vite SPA
│ ├── src/
│ │ ├── pages/ # File-based routes (vue-router)
│ │ ├── components/
│ │ ├── composables/
│ │ ├── utils/
│ │ │ └── ipc.ts # invoke()/listen() wrapper
│ │ ├── types/
│ │ │ ├── ipc/ # Generated by ts-rs (committed)
│ │ │ └── ui.ts # UI-only types
│ │ ├── App.vue
│ │ └── main.ts
│ ├── index.html
│ ├── vite.config.ts
│ └── package.json
├── src-tauri/ # Tauri shell + Rust backend
│ ├── Cargo.toml # workspace
│ ├── tauri.conf.json
│ ├── icons/
│ ├── build.rs
│ └── crates/
│ ├── core/ # FS domain logic, no Tauri deps
│ │ ├── src/
│ │ │ ├── agents.rs
│ │ │ ├── commands.rs
│ │ │ ├── skills.rs
│ │ │ ├── plans.rs
│ │ │ ├── output_styles.rs
│ │ │ ├── plugins.rs
│ │ │ ├── marketplace.rs
│ │ │ ├── mcp.rs
│ │ │ ├── projects.rs
│ │ │ ├── sessions.rs
│ │ │ ├── settings.rs
│ │ │ ├── frontmatter.rs
│ │ │ ├── relationships.rs
│ │ │ ├── claude_dir.rs
│ │ │ ├── git.rs
│ │ │ ├── models.rs # pricing / context window registry
│ │ │ ├── types/ # ts-rs derived shared types
│ │ │ └── lib.rs
│ │ └── tests/ # golden + snapshot tests
│ ├── pty/ # portable-pty wrapper, session manager
│ ├── watcher/ # notify + debouncer
│ ├── claude_cli/ # claude -p subprocess wrapper
│ └── app/ # Tauri binary: command bindings, AppState
│ ├── src/
│ │ ├── main.rs
│ │ ├── commands/ # one file per domain
│ │ ├── events.rs # event name constants
│ │ ├── error.rs # AppError + From impls
│ │ └── state.rs # AppState
│ └── Cargo.toml
└── docs/
- Vue 3 + Vite + vue-router. SPA, no SSR.
- State management. Strict separation:
- TanStack Query (Vue Query) for async server state — every
invoke()that fetches or mutates is wrapped in auseQuery/useMutation. Buys cache, dedupe, loading/error, and most importantly: aqueryClientinstance that thefs:changelistener can callinvalidateQueries({ queryKey: [...] })against. No bespoke refetch wiring. - Pinia for synchronous UI-only state — active tab, open modal id, sidebar collapse, command palette state, theme. No async, no fetches, no caching.
- Composables (
useAgents,useCommands, …) are thin wrappers that calluseQuerywith a stable query key derived from the IPC command name. They do not own data; thequeryClientdoes.
- TanStack Query (Vue Query) for async server state — every
- TypeScript everywhere. Generated
types/ipc/is the source of truth for backend payloads. - UI library: pick one —
@nuxt/uiis a Nuxt-only option; Tauri build defaults toradix-vue+ Tailwind. (Decision deferred to first PR.) - Routing: file-based router via
unplugin-vue-routeror hand-rolledvue-routerconfig. File-based keeps page mapping obvious. - No HTTP, no WebSocket. Only
invoke()andlisten().
Cargo workspace with five crates:
| Crate | Depends on | Responsibility |
|---|---|---|
core |
nothing Tauri-specific | All FS reads/writes, parsing, relationships, marketplace fetch, git ops. Fully testable with cargo test. |
pty |
core, portable-pty |
PTY session lifecycle. Owns the PtyManager. |
watcher |
core, notify |
Global file watcher + per-path subscriptions. |
claude_cli |
core |
Spawns claude -p --output-format stream-json for non-conversational SDK use cases. |
app |
all of the above + tauri |
Registers commands, owns AppState, plugin setup, deep links. |
core has zero Tauri dependencies. This is enforced by Cargo features and reviewed in PR.
Two channels:
- Commands. Request/response. Frontend calls
invoke('agents_list')and awaits a typed payload. Errors throw a typedAppError. - Events. Server → client push. Used for terminal output, file changes, long-running operation progress.
AppState (held by Tauri's State<AppState>):
pub struct AppState {
pty: Arc<PtyManager>, // tokio::Mutex<HashMap<Uuid, PtyHandle>>
watcher: Arc<WatcherHandle>,
claude_dir: Arc<RwLock<PathBuf>>,
claude_cli: Arc<RwLock<Option<ClaudeCliInfo>>>,
config: Arc<RwLock<AppConfig>>, // tauri-plugin-store backed
}- Per-session PTY tasks run on the Tokio runtime. Output is pushed via
app.emit("pty:output:{id}", payload). - The file watcher runs as a single global task. Subscribers are identified by
WatchSubscriptionIDs; each component listens onfs:changeand filters by path prefix. - Long-running async commands return a
request_idimmediately and emit progress events keyed by that id.
Top-level versions (pin major+minor; let minor revisions float):
# crates/core
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
ts-rs = { version = "9", features = ["serde-compat", "uuid-impl", "chrono-impl"] }
thiserror = "1"
anyhow = "1"
tracing = "0.1"
walkdir = "2"
glob = "0.3"
git2 = { version = "0.19", features = ["vendored-libgit2"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] }
sha2 = "0.10"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tokio = { version = "1", features = ["fs", "process", "sync", "macros", "rt-multi-thread", "io-util"] }
rmcp = "0.1"
# crates/pty
portable-pty = "0.8"
strip-ansi-escapes = "0.2" # ANSI scrub before regex parse in context monitor
# crates/watcher
notify = "6"
notify-debouncer-mini = "0.4"
ignore = "0.4" # gitignore-aware traversal + path exclusion (node_modules/, target/, .git/, …)
# crates/app
tauri = { version = "2", features = ["macos-private-api"] }
tauri-plugin-dialog = "2"
tauri-plugin-shell = "2"
tauri-plugin-fs = "2"
tauri-plugin-os = "2"
tauri-plugin-process = "2"
tauri-plugin-store = "2"
tauri-plugin-opener = "2"
tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-deep-link = "2"The on-disk layout is owned by the Claude CLI. claude-code-gui reads and writes it without migration.
~/.claude/
├── agents/ # markdown + YAML frontmatter (recursive)
├── commands/ # markdown + YAML frontmatter (recursive)
├── skills/<slug>/SKILL.md # one dir per skill
├── plans/ # plain markdown
├── output-styles/ # markdown + frontmatter (global)
├── projects/ # CLI session JSONL, one dir per encoded project path
│ └── -Users-foo-app/
│ ├── 01h2k3...jsonl
│ └── ...
├── plugins/ # marketplace-installed bundles
├── cli-history/ # PTY snapshot per session (claude-code-gui-owned)
├── settings.json # global settings
├── .mcp.json # global MCP server registry
├── .imports.json # GitHub skill import metadata
└── .marketplaces.json # configured marketplace sources
Per-project <projectPath>/.claude/:
output-styles/— project-scoped styles.mcp.json— project-scoped MCP serverssettings.json— project-scoped settingsCLAUDE.md— project conventions
ts-rs exports Rust types to TypeScript:
#[derive(Serialize, Deserialize, ts_rs::TS, Debug, Clone)]
#[ts(export, export_to = "../../../../frontend/src/types/ipc/")]
#[serde(rename_all = "camelCase")]
pub struct Agent {
pub slug: String,
pub filename: String,
pub directory: String,
pub frontmatter: AgentFrontmatter,
pub body: String,
pub has_memory: bool,
pub file_path: String,
}cargo test --workspace runs the export step. Generated .ts files are committed to frontend/src/types/ipc/. Frontend imports them verbatim.
Defined once in Rust (crates/core/src/types/), exported to TS via ts-rs:
| Domain | Types |
|---|---|
| Agents | Agent, AgentFrontmatter, AgentInput, AgentImport, AgentModel, AgentMemory, AgentTool |
| Commands | Command, CommandFrontmatter, CommandInput |
| Skills | Skill, SkillFrontmatter, SkillInput, SkillSource |
| Plans | Plan, PlanInput |
| Output Styles | OutputStyle, OutputStyleScope, OutputStyleInput |
| Plugins | Plugin, PluginDetail, AvailablePlugin, MarketplaceSource |
| MCP | McpServer, McpServerInput, McpCapabilities, McpTool, McpResource, McpPrompt |
| Projects | Project, ProjectInfo, FileNode, GitStatus, GitFileStatus |
| Sessions | SessionSummary, Message, MessageKind, Page<T>, TokenUsage |
| Terminal | TerminalSession, TerminalOpts, PermissionMode |
| Models | ModelMeta, ModelPricing |
| Errors | AppError, ErrorCode |
| Misc | Settings, AppConfig, ClaudeCliInfo, DirEntry, RequestId, SessionId |
---
name: Code Reviewer
description: Reviews PRs for security and clarity
model: sonnet # 'opus' | 'sonnet' | 'haiku'
color: "#7c3aed"
memory: user # 'user' | 'project' | 'local' | 'none'
skills: [refactor-helper]
tools: [Read, Grep, Bash]
---
You are a senior reviewer. Focus on...---
name: review-pr
description: Review the current PR
argument-hint: "[pr-number]"
allowed-tools: [Read, Bash]
agent: code-reviewer
---
Run a review against {{args}}...---
name: refactor-helper
description: Suggests safe refactors
context: when # 'when' | 'always'
agent: code-reviewer
---
When the user asks to refactor...---
name: Concise
description: Terse, no fluff
keepCodingInstructions: true
---
<style instructions body>Sessions live in ~/.claude/projects/<encoded>/<sessionId>.jsonl. The encoded path replaces / with - (/Users/foo/app → -Users-foo-app).
The Rust side parses each JSONL line with a tolerant deserializer (CLI line schemas drift across versions) and exposes a paginated Page<Message> for the session viewer:
pub struct Page<T> {
pub items: Vec<T>,
pub next_after: Option<usize>,
pub total: Option<usize>,
}
pub struct Message {
pub id: String,
pub kind: MessageKind,
pub role: Option<Role>,
pub timestamp: String,
pub content: Option<String>,
pub tool_name: Option<String>,
pub tool_input: Option<serde_json::Value>,
pub tool_result: Option<serde_json::Value>,
pub thinking: Option<String>,
pub is_error: bool,
}
pub enum MessageKind {
Text,
Thinking,
ToolUse,
ToolResult,
Image,
Status,
Error,
}There is no in-app concept of a chat session distinct from a CLI session. No JSONL written by claude-code-gui aside from cli-history/ PTY snapshots.
crates/core/src/models.rs:
pub struct ServerModelMeta {
pub api_id: &'static str,
pub input_price_per_mtok: f64,
pub output_price_per_mtok: f64,
pub cache_read_price_per_mtok: f64,
pub cache_write_price_per_mtok: f64,
pub context_window: u32,
}
pub const MODEL_ALIAS_KEY_OPUS: &str = "opus";
pub const MODEL_ALIAS_KEY_SONNET: &str = "sonnet";
pub const MODEL_ALIAS_KEY_HAIKU: &str = "haiku";
pub fn pricing(alias: &str) -> Option<&'static ServerModelMeta>;
pub fn context_window(alias: &str) -> Option<u32>;
pub fn resolve(alias_or_id: &str) -> Option<&'static ServerModelMeta>;Frontend has its own UI registry in frontend/src/utils/models.ts (label, color, tagline). Two registries on purpose: pricing changes don't trigger frontend rebuilds; UI palette changes don't touch the backend.
- Command names:
snake_case, prefixed by domain.agents_list,terminal_session_create. - All commands return
Result<T, AppError>. - Payload types defined in Rust, exported via
ts-rs. - Events use a hierarchical name with
:separators:pty:output:{session_id}. - Frontend never constructs raw paths to
~/.claude/; all path resolution happens in Rust.
#[derive(Serialize, ts_rs::TS, Debug)]
#[ts(export)]
pub struct AppError {
pub code: ErrorCode,
pub message: String,
pub cause: Option<String>,
}
#[derive(Serialize, ts_rs::TS, Debug)]
#[ts(export)]
pub enum ErrorCode {
NotFound,
InvalidInput,
IoError,
YamlError,
JsonError,
ClaudeCli,
Mcp,
Git,
Network,
ResourceLimit,
PermissionDenied,
Internal,
}Frontend wrapper:
import { invoke } from '@tauri-apps/api/core'
import type { Agent, AgentInput } from '@/types/ipc'
export const createAgent = (input: AgentInput) =>
invoke<Agent>('agents_create', { input })agents_list() -> Vec<Agent>agents_get(slug: String) -> Agentagents_create(input: AgentInput) -> Agentagents_update(slug: String, input: AgentInput) -> Agentagents_delete(slug: String) -> ()agents_export(slug: String) -> Stringagents_import(payload: AgentImport) -> Agentagents_skills(slug: String) -> Vec<Skill>agents_skill_counts() -> HashMap<String, usize>agents_history_list(slug: String) -> Vec<HistoryEntry>agents_history_get(slug: String, id: String) -> HistoryEntryagents_history_delete(slug: String, id: String) -> ()agents_improve_instructions(input: ImproveRequest) -> RequestId(streams viaclaude:improve:{request_id}events)
commands_list() -> Vec<Command>commands_get(slug: String) -> Commandcommands_create(input: CommandInput) -> Commandcommands_update(slug: String, input: CommandInput) -> Commandcommands_delete(slug: String) -> ()commands_execute(slug: String, args: Option<String>, working_dir: Option<String>) -> SessionId(spawns a terminal session preloaded with the command body; returns the session id for the UI to attach to)
skills_list() -> Vec<Skill>skills_get(slug: String) -> Skillskills_create(input: SkillInput) -> Skillskills_update(slug: String, input: SkillInput) -> Skillskills_delete(slug: String) -> ()skills_export(slug: String) -> Vec<u8>skills_import(source: SkillImportSource) -> Vec<Skill>(supportsGithub { url },Local { path })
plans_list() -> Vec<Plan>plans_get(slug: String) -> Planplans_create(input: PlanInput) -> Planplans_update(slug: String, input: PlanInput) -> Planplans_delete(slug: String) -> ()
output_styles_list() -> Vec<OutputStyle>output_styles_get(id: String, scope: OutputStyleScope, working_dir: Option<String>) -> OutputStyleoutput_styles_create(input: OutputStyleInput) -> OutputStyleoutput_styles_delete(id: String, scope: OutputStyleScope, working_dir: Option<String>) -> ()
plugins_list() -> Vec<Plugin>plugins_get(id: String) -> PluginDetailplugins_delete(id: String) -> ()plugins_set_enabled(id: String, enabled: bool) -> ()plugins_update_skills(id: String, slugs: Vec<String>) -> ()
marketplace_available() -> Vec<AvailablePlugin>marketplace_install(name: String, source: String) -> RequestId(streams viamarketplace:install:{request_id}events)marketplace_uninstall(id: String) -> ()marketplace_sources_list() -> Vec<MarketplaceSource>marketplace_sources_add(input: MarketplaceSourceInput) -> ()marketplace_sources_remove(name: String) -> ()marketplace_sources_update(name: String) -> ()
mcp_list(scope: McpScope, working_dir: Option<String>) -> Vec<McpServer>mcp_get(name: String, scope: McpScope, working_dir: Option<String>) -> McpServermcp_create(input: McpServerInput, scope: McpScope, working_dir: Option<String>) -> McpServermcp_delete(name: String, scope: McpScope, working_dir: Option<String>) -> ()mcp_capabilities(name: String, scope: McpScope, working_dir: Option<String>) -> McpCapabilitiesmcp_import(payload: McpImportPayload) -> Vec<McpServer>
projects_list() -> Vec<Project>projects_get(name: String) -> Projectprojects_create(path: String) -> Projectprojects_delete(name: String) -> ()projects_rename(name: String, new_name: String) -> ()projects_files(name: String, sub_path: Option<String>) -> Vec<FileNode>projects_git_status(name: String) -> GitStatusprojects_settings_get(name: String) -> Settingsprojects_settings_put(name: String, settings: Settings) -> ()projects_claude_md_get(name: String) -> Stringprojects_claude_md_put(name: String, content: String) -> ()projects_resolve(path: String) -> ProjectInfo
sessions_list_for_project(name: String) -> Vec<SessionSummary>sessions_messages(session_id: String, after_index: Option<usize>, limit: Option<usize>) -> Page<Message>sessions_rename(session_id: String, new_name: String) -> ()sessions_delete(project_name: String, session_id: String) -> ()
settings_get() -> Settingssettings_put(settings: Settings) -> ()config_get() -> AppConfigconfig_set(config: AppConfig) -> ()setup_finalize(payload: SetupPayload) -> ()
directories_list(parent: String) -> Vec<DirEntry>files_read(path: String) -> Stringreveal_in_finder(path: String) -> ()pick_folder() -> Option<String>(usestauri-plugin-dialog)
terminal_session_create(opts: TerminalOpts) -> SessionIdterminal_session_input(session_id: String, data: String) -> ()terminal_session_resize(session_id: String, cols: u16, rows: u16) -> ()terminal_session_kill(session_id: String) -> ()terminal_sessions_list() -> Vec<TerminalSession>terminal_session_get(session_id: String) -> TerminalSession
TerminalOpts:
#[derive(Deserialize, ts_rs::TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct TerminalOpts {
pub agent_slug: Option<String>,
pub working_dir: Option<String>,
pub cols: u16,
pub rows: u16,
pub model: Option<String>,
pub permission_mode: Option<PermissionMode>,
pub output_style_id: Option<String>,
pub resume_session_id: Option<String>,
pub command_template: Option<String>, // for commands_execute
}
#[derive(Deserialize, Serialize, ts_rs::TS)]
#[ts(export)]
pub enum PermissionMode {
Default,
AcceptEdits,
BypassPermissions,
Plan,
}debug_claude_cli() -> ClaudeCliInfoapp_version() -> Stringapp_open_url(url: String) -> ()
| Event | Payload | Source | Consumer |
|---|---|---|---|
pty:output:{session_id} |
{ data: string } |
PTY reader task | Terminal.vue |
pty:exit:{session_id} |
{ exitCode: number } |
PTY reader task | Terminal.vue |
fs:change |
{ path, kind: 'create' | 'modify' | 'delete' } |
global watcher | list pages, file tree |
fs:flood |
{ subscriptionId, root, eventsPerSec } |
global watcher rate-limiter | UI banner ("watcher paused on <root> due to event flood") |
claude:improve:{request_id} |
{ kind: 'delta' | 'done' | 'error', text?, error? } |
claude_cli::improve_instructions |
improve modal |
context:tokens:{session_id} |
{ input, output, cached, cost, model } |
context monitor | MetricsCard.vue |
context:tool:{session_id} |
ToolCall |
context monitor | ToolTimeline.vue |
marketplace:install:{request_id} |
{ kind: 'progress' | 'done' | 'error', step?, error? } |
marketplace task | install modal |
app:claude_dir_changed |
{ path: string } |
settings command | every page |
app:single_instance |
{ args: string[] } |
tauri-plugin-single-instance |
router (deep-link handling) |
- Long-running tasks identified by
request_idorsession_id. Command call returns the id; progress arrives via events; frontend correlates by id. - Soft caps:
- 16 simultaneous PTY sessions
- 4 concurrent
claude -psubprocesses
- Beyond cap →
AppError { code: ResourceLimit }.
tauri.conf.json:
Project working directories are added dynamically at runtime via fs.scope.allow (Tauri 2 supports runtime scope mutation).
The shell plugin is not allowed for arbitrary commands. PTY and claude -p use tokio::process::Command from inside Rust; only hard-coded binary names are spawned.
File-based router under frontend/src/pages/. Default landing page: /agents.
| Path | File | Purpose |
|---|---|---|
/ |
(redirect) | → /agents |
/agents |
pages/agents/index.vue |
List + create agents |
/agents/:slug |
pages/agents/[slug].vue |
Edit agent + embedded test terminal |
/commands |
pages/commands/index.vue |
Slash command list |
/commands/:slug |
pages/commands/[slug].vue |
Edit command |
/skills |
pages/skills/index.vue |
Skill list (local + plugin) |
/skills/:slug |
pages/skills/[slug].vue |
Edit skill |
/plans |
pages/plans/index.vue |
Plan list |
/plans/:slug |
pages/plans/[slug].vue |
Plan editor |
/mcp |
pages/mcp/index.vue |
MCP server list |
/mcp/:name |
pages/mcp/[name].vue |
MCP detail + capabilities probe |
/output-styles |
pages/output-styles/index.vue |
Output style manager |
/plugins |
pages/plugins/index.vue |
Installed + Discover tabs |
/plugins/:id |
pages/plugins/[id].vue |
Plugin detail |
/sessions |
pages/sessions/index.vue |
Project picker |
/sessions/project/:projectName |
pages/sessions/project/[projectName]/index.vue |
Project view: session list + git status |
/sessions/project/:projectName/session/:sessionId |
…/session/[sessionId].vue |
Session viewer + Resume terminal |
/sessions/project/:projectName/settings |
…/settings.vue |
Project settings + CLAUDE.md editor |
/settings |
pages/settings.vue |
Global settings |
| Component | Used in |
|---|---|
AppShell |
Root layout: sidebar, header, content slot |
Sidebar |
Nav with section counts |
PageHeader |
Title + action row |
GlobalSearch |
Header search across agents/commands/skills/plans |
OnboardingFlow |
First-run wizard, gated by settings.onboardingCompleted |
AgentCard, AgentForm, AgentWizard |
Agent CRUD UI |
CommandForm, SkillForm |
Form components |
ChatTerminal |
Embedded xterm-based terminal (replaces ChatPanel) |
MetricsCard, FileTree, ToolTimeline, SessionHistory |
Context panel pieces |
AddMcpModal, AddPluginModal, AddOutputStyleModal, AddMarketplaceModal |
Creation modals |
MarketplaceSourceRow |
Source row in marketplace tab |
HelpTip, FeatureCallout |
Inline guidance |
FileImport |
Import flows |
Two-pane:
- Left:
AgentForm— frontmatter (name, model, color, memory, skills, tools, directory) + body (markdown). - Right:
ChatTerminalmounted with the agent preloaded:await invoke('terminal_session_create', { opts: { agentSlug: agent.slug, workingDir: workingDir.value, cols: term.cols, rows: term.rows, model: agent.frontmatter.model, permissionMode: settings.value.defaultPermissionMode, } })
Above the terminal: a claude --resume tip showing the latest session id derived from ~/.claude/projects/.
Read-only history pane backed by sessions_messages. A "Resume in terminal" button opens a ChatTerminal with resumeSessionId set, spawning claude --resume <id> in the same project's working directory.
Two tabs:
- Installed: from
plugins_list() - Discover: from
marketplace_available(). Sources managed inline at the top.
Install action calls marketplace_install, listens on marketplace:install:{id}, refreshes the list on kind: 'done'.
Sections:
- Claude directory (override path, persisted via
tauri-plugin-store) - Claude CLI binary (probe path + version via
debug_claude_cli) - Default permission mode for new terminals
- Default model for new agents
- Hooks editor (raw JSON)
- Output style preferences
- Onboarding redo
- Updater channel + check now
- Server state: TanStack Query owns it. Composables (
useAgents,useCommands, …) wrapuseQuerywith a stable key per IPC command (e.g.['agents', 'list'],['agents', 'get', slug]). Mutations useuseMutationand callqueryClient.invalidateQuerieson success. - UI state: Pinia stores hold ephemeral, synchronous values only — active modal, sidebar open/closed, command palette state, theme.
- fs:change wiring: a single global listener at app root maps
path→ invalidated query keys (e.g.~/.claude/agents/**→['agents']). No component-levelfs:changehandlers. - No optimistic updates by default. Mutations invalidate, then refetch.
- Forms use
useUnsavedChangesto block route navigation when dirty. - Drafts persist to
localStorageviauseDraftRecoveryfor crash recovery.
Each nav item shows a badge with the entity count. Counts read from the same useQuery(['agents', 'list']) etc. caches the list pages use — no extra fetch. The fs:change → invalidateQueries plumbing keeps them live without bespoke wiring.
The terminal IS the chat. There is no separate streaming chat layer.
ChatTerminal.vue wraps xterm.js + @xterm/addon-fit + @xterm/addon-web-links:
<script setup lang="ts">
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { useDebounceFn } from '@vueuse/core'
import { listen } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core'
import type { TerminalOpts } from '@/types/ipc'
const props = defineProps<{ opts: TerminalOpts }>()
const sessionId = ref<string>()
onMounted(async () => {
const term = new Terminal({ fontFamily: 'Geist Mono', fontSize: 13 })
const fit = new FitAddon()
term.loadAddon(fit)
term.loadAddon(new WebLinksAddon())
term.open(el.value!)
fit.fit()
const id = await invoke<string>('terminal_session_create', { opts: props.opts })
sessionId.value = id
const unlisten = await listen<{ data: string }>(`pty:output:${id}`, (e) => {
term.write(e.payload.data)
})
term.onData((data) => invoke('terminal_session_input', { sessionId: id, data }))
// Debounce resize: xterm fires onResize on every grid recompute. Dragging the
// window edge can fan out to dozens of IPC + ioctl(TIOCSWINSZ) calls/sec,
// which spikes CPU and races inside the PTY backend. Coalesce to ~10 Hz.
const debouncedResize = useDebounceFn(
(cols: number, rows: number) => invoke('terminal_session_resize', { sessionId: id, cols, rows }),
100,
)
term.onResize(({ cols, rows }) => debouncedResize(cols, rows))
onBeforeUnmount(async () => {
unlisten()
if (sessionId.value) await invoke('terminal_session_kill', { sessionId: sessionId.value })
})
})
</script>pub struct PtyManager {
sessions: Arc<tokio::sync::Mutex<HashMap<Uuid, PtySession>>>,
app: AppHandle,
}
struct PtySession {
handle: Box<dyn portable_pty::MasterPty + Send>,
writer: Box<dyn std::io::Write + Send>,
child: Box<dyn portable_pty::Child + Send + Sync>,
meta: TerminalMeta,
output_buffer: Mutex<RingBuffer<String>>, // last 10K lines
}
impl PtyManager {
pub async fn create(&self, opts: TerminalOpts) -> Result<Uuid> {
let id = Uuid::new_v4();
let pty_system = native_pty_system();
let pair = pty_system.openpty(PtySize {
rows: opts.rows, cols: opts.cols, pixel_width: 0, pixel_height: 0
})?;
let cmd = compose_command(&opts).await?;
let child = pair.slave.spawn_command(cmd)?;
// Spawn reader task
let app = self.app.clone();
let reader = pair.master.try_clone_reader()?;
tokio::spawn(reader_task(id, app.clone(), reader));
// Spawn exit watcher
tokio::spawn(exit_watcher(id, app.clone(), child_clone));
// Insert session
self.sessions.lock().await.insert(id, PtySession { /* ... */ });
Ok(id)
}
pub async fn input(&self, id: Uuid, data: &[u8]) -> Result<()> { /* writer.write_all */ }
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<()> { /* handle.resize */ }
pub async fn kill(&self, id: Uuid) -> Result<()> { /* child.kill */ }
}async fn compose_command(opts: &TerminalOpts) -> Result<CommandBuilder> {
// Resolve target: agent-bound, command-template, or bare claude with
// --resume. All three end up invoking the `claude` binary; the terminal
// subsystem is strictly a Claude wrapper and never spawns an arbitrary
// shell. See §8 Security model.
let claude = core::claude_cli::path()?;
let mut cmd = CommandBuilder::new(&claude);
if let Some(slug) = &opts.agent_slug {
let agent = core::agents::get(slug)?;
cmd.arg("--append-system-prompt").arg(agent.body);
if let Some(model) = agent.frontmatter.model.or(opts.model.clone()) {
cmd.arg("--model").arg(model);
}
} else if opts.resume_session_id.is_none() && opts.command_template.is_none() {
// No agent, no resume, no command → nothing to launch. Fail loudly
// rather than silently dropping the user into $SHELL with this app's
// OS-level entitlements (fs scope on ~/.claude/**, deep-link handler,
// etc.). The fs and shell capabilities here are sized for Claude;
// exposing them to a generic interactive shell breaks the threat model.
return Err(AppError {
code: ErrorCode::InvalidInput,
message: "Terminal requires an agent slug, resume session id, or command template.".into(),
cause: None,
});
}
if let Some(mode) = &opts.permission_mode {
cmd.arg("--permission-mode").arg(mode.as_cli_flag());
}
if let Some(style) = &opts.output_style_id {
cmd.arg("--output-style").arg(style);
}
if let Some(resume) = &opts.resume_session_id {
cmd.arg("--resume").arg(resume);
}
if let Some(wd) = &opts.working_dir {
cmd.cwd(wd);
}
Ok(cmd)
}If a user wants a generic terminal (git, build commands, etc.) they should use their actual terminal emulator. claude-code-gui is not a terminal app.
- Idle for 30 min → killed (configurable).
- Output buffered in memory (last 10K lines).
- On exit: metadata + buffer snapshotted to
~/.claude/cli-history/<id>.json. - App quit → all PTYs killed cleanly via
app.on_window_event(CloseRequested, ...).
The Claude CLI prints [y/n] prompts inline. The user types directly into the PTY. There is no out-of-band permission UI in claude-code-gui — we don't intercept tool calls.
For users who want to skip prompts, the launcher exposes permissionMode: 'bypassPermissions' in TerminalOpts.
The single non-PTY use of the CLI: agents_improve_instructions. Spawns claude -p --output-format stream-json --input-format stream-json, writes a JSON message to stdin, reads stdout line-by-line, emits claude:improve:{request_id} events with kind: 'delta' | 'done' | 'error'.
pub async fn improve_instructions(
app: AppHandle,
request_id: Uuid,
input: ImproveRequest,
) -> Result<()> {
let mut child = Command::new(claude_path()?)
.args(["-p", "--output-format", "stream-json",
"--input-format", "stream-json",
"--append-system-prompt", &input.system])
.stdin(Stdio::piped()).stdout(Stdio::piped())
.spawn()?;
let stdin = child.stdin.take().unwrap();
write_stream_json_message(stdin, &input.prompt).await?;
let stdout = child.stdout.take().unwrap();
let mut lines = BufReader::new(stdout).lines();
while let Some(line) = lines.next_line().await? {
let event: StreamJsonEvent = serde_json::from_str(&line)?;
match event.kind() {
"text_delta" => emit(&app, format!("claude:improve:{request_id}"),
json!({ "kind": "delta", "text": event.text })),
"result" => { emit(&app, ..., json!({ "kind": "done" })); break; }
_ => {}
}
}
Ok(())
}pub struct WatcherHandle {
debouncer: Debouncer<RecommendedWatcher, FileIdMap>,
subscriptions: Arc<RwLock<HashMap<Uuid, Subscription>>>,
/// Compiled gitignore matcher per subscription root, plus hard-coded
/// always-ignore globs (node_modules, target, .git, dist, build, .venv,
/// __pycache__, .next, .nuxt, .turbo, .cache, *.lock-only churn, …).
/// Built via `ignore::gitignore::GitignoreBuilder` rooted at the watched
/// path, then layered with the global denylist. Path filtering happens
/// **before** events leave the notify thread, so debouncer + IPC bridge
/// never see the noise.
matchers: Arc<RwLock<HashMap<Uuid, Gitignore>>>,
}
struct Subscription {
root: PathBuf,
/// `true` for `~/.claude/**` (no ignore — every file matters).
/// `false` for project working dirs (gitignore + denylist applied).
is_claude_dir: bool,
}
pub fn start_global(app: AppHandle) -> Result<WatcherHandle>;
pub fn watch_claude_dir(handle: &WatcherHandle, path: &Path) -> Result<Uuid>;
pub fn watch_project(handle: &WatcherHandle, path: &Path) -> Result<Uuid>;
pub fn unwatch(handle: &WatcherHandle, id: Uuid);Powered by notify-debouncer-mini with a 200 ms debounce, gated by an ignore-crate matcher so a single npm install or cargo build cannot flood the bridge with thousands of node_modules/ / target/ events. Each filesystem event that survives the matcher becomes:
// emit "fs:change"
{ "path": "/Users/foo/.claude/agents/reviewer.md", "kind": "modify" }The watcher always covers ~/.claude/ (no ignore — every file matters there). Project working directories are added dynamically when the user opens a project page, and are filtered through:
- Project-local
.gitignore(and parent.gitignorechain), viaignore::gitignore::GitignoreBuilder. - A hard-coded global denylist that runs even when no
.gitignoreexists:node_modules/,target/,.git/,dist/,build/,.next/,.nuxt/,.turbo/,.cache/,.venv/,venv/,__pycache__/,.DS_Store. - A max event-rate circuit breaker per subscription: if a single root emits > 500 post-filter events in a 1 s window, the subscription is paused for 5 s and a
fs:floodevent is emitted to the UI for surfacing as a non-blocking banner.
Frontend filters by path prefix client-side as a second layer (cheap, defensive). Debounce + ignore + rate-limit keeps event volume well under the chatter threshold even on noisy monorepos.
Per-session. The PTY reader pipes each chunk through parse_chunk:
fn parse_chunk(chunk: &str, state: &mut MonitorState, app: &AppHandle, id: Uuid) {
state.line_buf.push_str(chunk);
while let Some(raw_line) = state.line_buf.next_line() {
// The PTY stream is full of ANSI control sequences (SGR colors, bold,
// cursor moves, OSC titles). A regex like `\[tool\] Grep started`
// never matches `\x1b[32m[tool]\x1b[0m \x1b[1mGrep\x1b[0m started`.
// Scrub once, then match. The original `raw_line` is forwarded
// untouched to xterm.js — only the parser sees the cleaned form.
let line = String::from_utf8(
strip_ansi_escapes::strip(raw_line.as_bytes())
).unwrap_or_default();
// 1. tokens: <n> in, <n> out, <n> cache_read, <n> cache_write
if let Some(tokens) = TOKEN_RE.captures(&line) {
let usage = TokenUsage::from(tokens);
if let Some(model) = state.last_model.as_deref() {
let cost = compute_cost(model, &usage);
emit(app, format!("context:tokens:{id}"), json!({
"input": usage.input,
"output": usage.output,
"cached": usage.cache_read,
"cost": cost,
"model": model,
}));
}
}
// 2. usage: ... model=<slug>
if let Some(m) = MODEL_RE.captures(&line) {
state.last_model = Some(m[1].to_string());
}
// 3. [tool] <Name> started / completed in <ms>ms
if let Some(tc) = TOOL_RE.captures(&line) {
emit(app, format!("context:tool:{id}"), tc.into_event());
}
}
}
fn compute_cost(model: &str, u: &TokenUsage) -> f64 {
let m = core::models::resolve(model).unwrap_or_default();
(u.input as f64 * m.input_price_per_mtok / 1e6)
+ (u.output as f64 * m.output_price_per_mtok / 1e6)
+ (u.cache_read as f64 * m.cache_read_price_per_mtok / 1e6)
+ (u.cache_write as f64 * m.cache_write_price_per_mtok / 1e6)
}Caveat: regex-based parsing is brittle to upstream Claude CLI output format changes. Each claude upgrade requires a smoke test of the metric panel.
The CLI doesn't emit stream-json in interactive PTY mode. Only -p does. So context monitoring during a real conversation has to be regex-based. Tradeoff accepted.
# Dev
cd src-tauri && cargo tauri dev # auto-runs `bun --cwd ../frontend dev`
# Release
cargo tauri build # produces signed installers| Platform | Artifacts | Target size |
|---|---|---|
| macOS | .dmg, .app.tar.gz (universal2: aarch64-apple-darwin + x86_64-apple-darwin) |
< 18 MB |
| Windows | .msi, .exe (NSIS) |
< 12 MB |
| Linux | .AppImage, .deb, .rpm |
< 18 MB |
{
"productName": "Claude Code GUI",
"version": "0.1.0",
"identifier": "com.anthropic.claude-code-gui",
"build": {
"frontendDist": "../frontend/dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "bun --cwd ../frontend dev",
"beforeBuildCommand": "bun --cwd ../frontend build"
},
"app": {
"windows": [{
"label": "main",
"title": "Claude Code GUI",
"width": 1440,
"height": 900,
"minWidth": 960,
"minHeight": 600,
"decorations": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"visible": true
}],
"security": {
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:"
}
},
"bundle": {
"active": true,
"targets": ["dmg", "app", "msi", "appimage", "deb", "rpm"],
"category": "DeveloperTool",
"shortDescription": "Visual manager for Claude Code",
"longDescription": "Manage Claude Code agents, commands, skills, plugins, and sessions.",
"macOS": {
"minimumSystemVersion": "12.0",
"signingIdentity": "Developer ID Application: ...",
"entitlements": "entitlements.plist"
},
"windows": {
"wix": { "language": "en-US" },
"certificateThumbprint": null
}
},
"plugins": {
"updater": {
"endpoints": ["https://updates.example.com/{{target}}/{{current_version}}"],
"pubkey": "..."
},
"deep-link": {
"schemes": ["claude-code-gui"]
}
}
}- macOS: Apple Developer ID Application + notarization. Run
xcrun notarytoolpost-build via CI; staple ticket back into the bundle. - Windows: Authenticode signing certificate. Configure
bundle.windows.certificateThumbprint. - Linux: optional GPG signing for repo distribution. AppImage doesn't strictly require signing.
tauri-plugin-updater:
- Update channel selection in
/settings(stable/beta). - Manifest endpoint returns JSON with
version,notes,pub_date, per-target signed bundle URLs. - Public key embedded in binary at build time. Private key stored in CI secrets.
tauri-plugin-single-instance: second invocation routes args to the existing window.
claude-code-gui://install/<plugin-name>?source=<source>: handled by routing to the marketplace install flow.
| Var | Default | Effect |
|---|---|---|
CLAUDE_DIR |
~/.claude |
Override base dir. Read once at boot; UI override persisted via tauri-plugin-store. |
CLAUDE_CLI_PATH |
resolved via which |
Path to claude binary |
RUST_LOG |
info |
tracing filter |
No PORT / HOST — there is no listening socket.
tracing + tracing-appender:
- macOS:
~/Library/Logs/com.anthropic.claude-code-gui/app.log - Windows:
%LOCALAPPDATA%\com.anthropic.claude-code-gui\logs\ - Linux:
~/.local/share/com.anthropic.claude-code-gui/logs/
Daily rotation, 14-day retention.
- Rust panics in commands caught at the IPC boundary, returned as
AppError { code: Internal }. - PTY tasks that panic emit
pty:exit:{id}withexitCode: -1and clean up state. - File watcher recovers from transient
notifyerrors; permanent errors set the watcher to a degraded state and emit a UI banner. - App quit cleanly kills all PTYs, awaits flush, persists
cli-history/.
- No network listener. IPC is in-process only.
fsplugin scoped to~/.claude/**and user-selected project dirs. No arbitrary filesystem access.shellplugin denies arbitrary command execution. Only Rust-sidetokio::process::Commandspawns processes, with hard-coded binary names.- Marketplace fetch uses
reqwestwithrustls-tls(system trust roots). No HTTP plaintext. - Git operations use
git2withvendored-libgit2; HTTPS only; GPG verification optional and per-source. - WebView CSP locks down inline scripts and remote resources. Only fonts and data URLs allowed.
- Deep links validated against an allow-list of action verbs (
install,open-agent).
- Define input/output structs in
crates/core/src/types/<domain>.rs. DeriveSerialize, Deserialize, ts_rs::TS, Debug, Clone. - Implement the function in
crates/core/src/<domain>.rs. No Tauri imports. - Add a thin wrapper in
crates/app/src/commands/<domain>.rs:
#[tauri::command]
pub async fn agents_create(
state: State<'_, AppState>,
input: AgentInput,
) -> Result<Agent, AppError> {
core::agents::create(input).map_err(Into::into)
}- Register in the
tauri::generate_handler!macro incrates/app/src/main.rs. - Frontend: add a thin invoker in
frontend/src/utils/ipc.ts:
export const createAgent = (input: AgentInput) =>
invoke<Agent>('agents_create', { input })- Run
cargo test --workspaceto regeneratefrontend/src/types/ipc/.
- Define payload type in
crates/core/src/types/events.rs. DeriveSerialize, ts_rs::TS. - Add the event name constant in
crates/app/src/events.rs. - Emit via
app.emit(event_name, payload)?. - Frontend: subscribe via
listen(event_name, handler)from@tauri-apps/api/event. Tear down on unmount.
crates/core/src/models.rs — add to MODEL_ALIAS and SERVER_MODEL_META (alias key, full Anthropic id, pricing, context window).
Frontend frontend/src/utils/models.ts — add to MODEL, MODEL_IDS, MODEL_META (UI metadata only — label, color, tagline).
ts-rs regenerates frontend/src/types/ipc/ModelMeta.ts automatically.
Comparisons in code use named constants only:
import { MODEL } from '@/utils/models'
if (model === MODEL.SONNET) { /* ... */ }use core::models::MODEL_ALIAS_KEY_SONNET;
if model == MODEL_ALIAS_KEY_SONNET { /* ... */ }No raw model strings in logic. Ever.
crates/core/src/marketplace.rs:
pub enum SourceType {
Github,
Http,
// Add new variants here
}Add a fetch branch + install branch. Frontend AddMarketplaceModal already has a sourceType dropdown — add the option there.
- Add a variant to
PermissionModeincrates/core/src/types/permission.rs. - Update
PermissionMode::as_cli_flag()to translate it to the matchingclaudeCLI flag. - Update the picker component in the agent test panel.
To add e.g. Snippets stored under ~/.claude/snippets/<slug>.md:
- Types:
Snippet,SnippetFrontmatter,SnippetInputincrates/core/src/types/snippets.rs - Domain logic:
crates/core/src/snippets.rs(list,get,create,update,delete) - Commands:
crates/app/src/commands/snippets.rs - Register in
generate_handler! - Frontend composable:
useSnippetswrapping the invokers - Pages:
frontend/src/pages/snippets/index.vueand[slug].vue - Sidebar entry
- (Optional) hook into
crates/core/src/relationships.rsso snippets show up in relationship queries
cargo test --workspacecovers everycore::*function with temp-dirCLAUDE_DIRfixtures.- Snapshot tests via
instafor frontmatter round-tripping. - Integration test that spawns
catas a fake PTY end-to-end. fixtures/directory copied from a real~/.claude/for golden tests of session JSONL parsing.- Frontend: minimal — visual regression via Playwright on a handful of golden screenshots. Skip unit tests for Vue.
Phased delivery. Each phase is independently shippable as a private build.
-
cargo newworkspace + Tauri 2 init (bun create tauri-app) - Vue 3 + Vite + vue-router + Tailwind in
frontend/ -
ts-rsexport pipeline; first generated types committed - CI matrix: macOS / Windows / Linux on
cargo tauri build --debug - No-op WebView showing the SPA
- App shell, sidebar, empty pages for every route
-
claude_dirresolution +tauri-plugin-storefor override persistence -
frontmatterparser + round-trip tests - List/get for: agents, commands, skills, plans, output-styles, mcp, plugins, projects, sessions
- Settings + config + debug_claude_cli
- Acceptance: every list page renders correctly against an existing
~/.claude/.
- Create/update/delete for all CRUD entities
- Import/export for agents, skills
-
setup_finalize(first-run wizard) -
directories_list,files_read,reveal_in_finder,pick_folder - Project create/rename/delete + git status
-
claude_cli::improve_instructions+claude:improve:{id}events -
marketplace_installprogress events - File watcher +
fs:changeevents - Marketplace source CRUD + plugin install/uninstall
-
ptycrate +portable-ptyintegration -
terminal_*commands andpty:output:{id}/pty:exit:{id}events - Context monitor:
context:tokens:{id}/context:tool:{id}events -
ChatTerminal.vuecomponent - Embed in agent detail right pane
- Embed in session viewer (Resume mode)
- Permission mode flag wiring
-
rmcpintegration for capability probing - MCP CRUD pages + capability detail page
- Relationship extractor (agent ↔ skill, agent ↔ command)
- Code-signing certificates (Apple Developer ID, Windows authenticode)
- CI build + sign + notarize per platform
- Auto-updater manifest pipeline
- Deep-link handler (
claude-code-gui://install/...) - Single-instance plugin
- Release notes template + version bump scripts
- Public beta release
Before shipping 1.0:
- All Goals from §1 satisfied
- A user with an existing
~/.claude/can install the app, open it, and see all of their agents/commands/skills/plugins/sessions without any migration step - The terminal works against
claudeversions ≥ 2.0 - Bundle size under 20 MB on all three platforms
- No runtime dependency on Node, npm, or any TS server code
- CI green on all three platforms
- Code signed and notarized
- Auto-updater verified end-to-end on at least one platform
- Image attachments in terminal (Claude CLI doesn't expose them via PTY interactively)
- Custom MCP server templates
- Cross-machine sync (no cloud component planned)
- Alternative LLM providers —
claude-code-guiis Claude-only by design - Embedded model gateway / local proxy
{ "permissions": [ "core:default", "event:default", "dialog:default", "shell:open", "store:default", "process:default", "opener:default", "updater:default", "deep-link:default", { "identifier": "fs:scope", "allow": [ { "path": "$HOME/.claude/**" } ] } ] }