Compare commits

...

6 Commits

Author SHA1 Message Date
4732feb33e Add Ctrl+Shift+C keyboard shortcut for copying terminal text
All checks were successful
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 3m25s
Build App / build-linux (push) Successful in 5m33s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Ctrl+C in the terminal sends SIGINT which cancels running Claude work.
This adds a custom key handler so Ctrl+Shift+C copies selected text to
the clipboard without interrupting the container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:05:10 -07:00
5977024953 Update Ollama docs and UI to mark model as required
All checks were successful
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 3m25s
Build App / build-linux (push) Successful in 4m48s
Build App / create-tag (push) Successful in 9s
Build App / sync-to-github (push) Successful in 14s
The model field must be set and the model must be pre-pulled in Ollama
before the container will work. Updated README, HOW-TO-USE, and the
ProjectCard UI label/tooltip to reflect this.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:53:24 -07:00
27007b90e3 Fetch help content from repo, add TOC and marketplace troubleshooting
All checks were successful
Build App / compute-version (push) Successful in 6s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m57s
Build App / build-linux (push) Successful in 5m2s
Build App / create-tag (push) Successful in 5s
Build App / sync-to-github (push) Successful in 10s
Help dialog now fetches HOW-TO-USE.md live from the gitea repo on open,
falling back to the compile-time embedded copy when offline. Content is
cached for the session. Removes the ~600-line hardcoded markdown constant
from HelpDialog.tsx in favor of a single source of truth.

Adds a Table of Contents with anchor links for quick navigation and a new
troubleshooting entry for the "Failed to install Anthropic marketplace"
error with the jq fix. Markdown renderer updated to support anchor links
and header id attributes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:00:59 -07:00
38e65619e9 Fix tooltips clipped by overflow containers, improve Backend tooltip text
All checks were successful
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m28s
Build App / build-linux (push) Successful in 5m44s
Build App / create-tag (push) Successful in 6s
Build App / sync-to-github (push) Successful in 12s
Rewrite Tooltip to use React portal (createPortal to document.body) so
tooltips render above all UI elements regardless of ancestor overflow:hidden.
Also increased max-width from 220px to 280px for longer descriptions.

Expanded Backend tooltip to explain each option (Anthropic, Bedrock,
Ollama, LiteLLM) with practical context for new users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:56:50 -07:00
d2c1c2108a Fix update checker to use full semver comparison and correct platform filtering
Some checks failed
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 2m20s
Build App / build-windows (push) Successful in 3m28s
Build App / build-linux (push) Successful in 5m21s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Has been cancelled
The version comparison was only comparing the patch number, ignoring major
and minor versions. This meant 0.1.75 (patch=75) appeared "newer" than
0.2.1 (patch=1), and updates within 0.2.x were missed entirely.

Also fixed platform filtering to handle -mac suffix (previously only
filtered -win, so Linux users would see macOS releases too).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:45:43 -07:00
cc163e6650 Add help dialog and tooltip indicators throughout the UI
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m29s
Build App / build-linux (push) Successful in 5m43s
Build App / create-tag (push) Successful in 7s
Build App / sync-to-github (push) Successful in 13s
- Add circled ? help button in TopBar that opens a dialog with HOW-TO-USE.md content
- Create reusable Tooltip component with viewport-aware positioning
- Add 32 tooltip indicators across project config and settings panels
- Covers backend selection, Bedrock/Ollama/LiteLLM fields, Docker, AWS, MCP, and more

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:35:04 -07:00
16 changed files with 629 additions and 66 deletions

View File

@@ -4,6 +4,25 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
---
## Table of Contents
- [Prerequisites](#prerequisites)
- [First Launch](#first-launch)
- [The Interface](#the-interface)
- [Project Management](#project-management)
- [Project Configuration](#project-configuration)
- [MCP Servers (Beta)](#mcp-servers-beta)
- [AWS Bedrock Configuration](#aws-bedrock-configuration)
- [Ollama Configuration](#ollama-configuration)
- [LiteLLM Configuration](#litellm-configuration)
- [Settings](#settings)
- [Terminal Features](#terminal-features)
- [Scheduled Tasks (Inside the Container)](#scheduled-tasks-inside-the-container)
- [What's Inside the Container](#whats-inside-the-container)
- [Troubleshooting](#troubleshooting)
---
## Prerequisites
### Docker
@@ -94,8 +113,9 @@ Claude Code launches automatically with `--dangerously-skip-permissions` inside
1. Stop the container first (settings can only be changed while stopped).
2. In the project card, switch the backend to **Ollama**.
3. Expand the **Config** panel and set the base URL of your Ollama server (defaults to `http://host.docker.internal:11434` for a local instance). Optionally set a model ID.
4. Start the container again.
3. Expand the **Config** panel and set the base URL of your Ollama server (defaults to `http://host.docker.internal:11434` for a local instance). Set the **Model ID** to the model you want to use (required).
4. Make sure the model has been pulled in Ollama (e.g., `ollama pull qwen3.5:27b`) or used via Ollama cloud before starting.
5. Start the container again.
**LiteLLM:**
@@ -395,7 +415,7 @@ To use Claude Code with a local or remote Ollama server, switch the backend to *
### Settings
- **Base URL** — The URL of your Ollama server. Defaults to `http://host.docker.internal:11434`, which reaches a locally running Ollama instance from inside the container. For a remote server, use its IP or hostname (e.g., `http://192.168.1.100:11434`).
- **Model ID** — Optional. Override the model to use (e.g., `qwen3.5:27b`).
- **Model ID** — **Required.** The model to use (e.g., `qwen3.5:27b`). The model must be pulled in Ollama before use — run `ollama pull <model>` or use it via Ollama cloud so it is available when the container starts.
### How It Works
@@ -403,6 +423,8 @@ Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your Ollama server in
> **Note:** Ollama support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models.
> **Important:** The model must already be available in Ollama before starting the container. If using a local Ollama instance, pull the model first with `ollama pull <model-name>`. If using Ollama's cloud service, ensure the model has been used at least once so it is cached.
---
## LiteLLM Configuration
@@ -622,3 +644,13 @@ You can install additional tools at runtime with `sudo apt install`, `pip instal
- Ensure the Docker image for the MCP server exists (pull it first if needed).
- Check that Docker socket access is available (stdio + Docker MCP servers auto-enable this).
- Try resetting the project container to force a clean recreation.
### "Failed to install Anthropic marketplace" Error
If Claude Code shows **"Failed to install Anthropic marketplace - Will retry on next startup"** repeatedly, the marketplace metadata in `~/.claude.json` may be corrupted. To fix this, open a **Shell** session in the project and run:
```bash
cp ~/.claude.json ~/.claude.json.bak && jq 'with_entries(select(.key | startswith("officialMarketplace") | not))' ~/.claude.json.bak > ~/.claude.json
```
This backs up your config and removes the corrupted marketplace entries. Claude Code will re-download them cleanly on the next startup.

View File

@@ -49,7 +49,7 @@ Each project can independently use one of:
- **Anthropic** (OAuth): User runs `claude login` inside the terminal on first use. Token persisted in the config volume across restarts and resets.
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Optional model override.
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container.
- **LiteLLM**: Connect through a LiteLLM proxy gateway via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` to access 100+ model providers. API key stored securely in OS keychain.
> **Note:** Ollama and LiteLLM support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.

View File

@@ -0,0 +1,60 @@
use std::sync::OnceLock;
use tokio::sync::Mutex;
const HELP_URL: &str =
"https://repo.anhonesthost.net/cybercovellc/triple-c/raw/branch/main/HOW-TO-USE.md";
const EMBEDDED_HELP: &str = include_str!("../../../../HOW-TO-USE.md");
/// Cached help content fetched from the remote repo (or `None` if not yet fetched).
static CACHED_HELP: OnceLock<Mutex<Option<String>>> = OnceLock::new();
/// Return the help markdown content.
///
/// On the first call, tries to fetch the latest version from the gitea repo.
/// If that fails (network error, timeout, etc.), falls back to the version
/// embedded at compile time. The result is cached for the rest of the session.
#[tauri::command]
pub async fn get_help_content() -> Result<String, String> {
let mutex = CACHED_HELP.get_or_init(|| Mutex::new(None));
let mut guard = mutex.lock().await;
if let Some(ref cached) = *guard {
return Ok(cached.clone());
}
let content = match fetch_remote_help().await {
Ok(md) => {
log::info!("Loaded help content from remote repo");
md
}
Err(e) => {
log::info!("Using embedded help content (remote fetch failed: {})", e);
EMBEDDED_HELP.to_string()
}
};
*guard = Some(content.clone());
Ok(content)
}
async fn fetch_remote_help() -> Result<String, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let resp = client
.get(HELP_URL)
.send()
.await
.map_err(|e| format!("Failed to fetch help content: {}", e))?;
if !resp.status().is_success() {
return Err(format!("Remote returned status {}", resp.status()));
}
resp.text()
.await
.map_err(|e| format!("Failed to read response body: {}", e))
}

View File

@@ -1,6 +1,7 @@
pub mod aws_commands;
pub mod docker_commands;
pub mod file_commands;
pub mod help_commands;
pub mod mcp_commands;
pub mod project_commands;
pub mod settings_commands;

View File

@@ -34,30 +34,37 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
.map_err(|e| format!("Failed to parse releases: {}", e))?;
let current_version = env!("CARGO_PKG_VERSION");
let is_windows = cfg!(target_os = "windows");
let current_semver = parse_semver(current_version).unwrap_or((0, 0, 0));
// Determine platform suffix for tag filtering
let platform_suffix: &str = if cfg!(target_os = "windows") {
"-win"
} else if cfg!(target_os = "macos") {
"-mac"
} else {
"" // Linux uses bare tags (no suffix)
};
// Filter releases by platform tag suffix
let platform_releases: Vec<&GiteaRelease> = releases
.iter()
.filter(|r| {
if is_windows {
r.tag_name.ends_with("-win")
if platform_suffix.is_empty() {
// Linux: bare tag only (no -win, no -mac)
!r.tag_name.ends_with("-win") && !r.tag_name.ends_with("-mac")
} else {
!r.tag_name.ends_with("-win")
r.tag_name.ends_with(platform_suffix)
}
})
.collect();
// Find the latest release with a higher patch version
// Version format: 0.1.X or v0.1.X (tag may have prefix/suffix)
let current_patch = parse_patch_version(current_version).unwrap_or(0);
let mut best: Option<(&GiteaRelease, u32)> = None;
// Find the latest release with a higher semver version
let mut best: Option<(&GiteaRelease, (u32, u32, u32))> = None;
for release in &platform_releases {
if let Some(patch) = parse_patch_from_tag(&release.tag_name) {
if patch > current_patch {
if best.is_none() || patch > best.unwrap().1 {
best = Some((release, patch));
if let Some(ver) = parse_semver_from_tag(&release.tag_name) {
if ver > current_semver {
if best.is_none() || ver > best.unwrap().1 {
best = Some((release, ver));
}
}
}
@@ -92,36 +99,34 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
}
}
/// Parse patch version from a semver string like "0.1.5" -> 5
fn parse_patch_version(version: &str) -> Option<u32> {
/// Parse a semver string like "0.2.5" -> (0, 2, 5)
fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
let clean = version.trim_start_matches('v');
let parts: Vec<&str> = clean.split('.').collect();
if parts.len() >= 3 {
parts[2].parse().ok()
let major = parts[0].parse().ok()?;
let minor = parts[1].parse().ok()?;
let patch = parts[2].parse().ok()?;
Some((major, minor, patch))
} else {
None
}
}
/// Parse patch version from a tag like "v0.1.5", "v0.1.5-win", "0.1.5" -> 5
fn parse_patch_from_tag(tag: &str) -> Option<u32> {
/// Parse semver from a tag like "v0.2.5", "v0.2.5-win", "v0.2.5-mac" -> (0, 2, 5)
fn parse_semver_from_tag(tag: &str) -> Option<(u32, u32, u32)> {
let clean = tag.trim_start_matches('v');
// Remove platform suffix
let clean = clean.strip_suffix("-win").unwrap_or(clean);
parse_patch_version(clean)
let clean = clean.strip_suffix("-win")
.or_else(|| clean.strip_suffix("-mac"))
.unwrap_or(clean);
parse_semver(clean)
}
/// Extract a clean version string from a tag like "v0.1.5-win" -> "0.1.5"
/// Extract a clean version string from a tag like "v0.2.5-win" -> "0.2.5"
fn extract_version_from_tag(tag: &str) -> Option<String> {
let clean = tag.trim_start_matches('v');
let clean = clean.strip_suffix("-win").unwrap_or(clean);
// Validate it looks like a version
let parts: Vec<&str> = clean.split('.').collect();
if parts.len() >= 3 && parts.iter().all(|p| p.parse::<u32>().is_ok()) {
Some(clean.to_string())
} else {
None
}
let (major, minor, patch) = parse_semver_from_tag(tag)?;
Some(format!("{}.{}.{}", major, minor, patch))
}
/// Check whether a newer container image is available in the registry.

View File

@@ -120,6 +120,8 @@ pub fn run() {
commands::update_commands::get_app_version,
commands::update_commands::check_for_updates,
commands::update_commands::check_image_update,
// Help
commands::help_commands::get_help_content,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -0,0 +1,218 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { getHelpContent } from "../../lib/tauri-commands";
interface Props {
onClose: () => void;
}
/** Convert header text to a URL-friendly slug for anchor links. */
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/<[^>]+>/g, "") // strip HTML tags (e.g. from inline code)
.replace(/[^\w\s-]/g, "") // remove non-word chars except spaces/dashes
.replace(/\s+/g, "-") // spaces to dashes
.replace(/-+/g, "-") // collapse consecutive dashes
.replace(/^-|-$/g, ""); // trim leading/trailing dashes
}
/** Simple markdown-to-HTML converter for the help content. */
function renderMarkdown(md: string): string {
let html = md;
// Normalize line endings
html = html.replace(/\r\n/g, "\n");
// Escape HTML entities (but we'll re-introduce tags below)
html = html.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// Fenced code blocks (```...```)
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
return `<pre class="help-code-block"><code>${code.trimEnd()}</code></pre>`;
});
// Inline code (`...`)
html = html.replace(/`([^`]+)`/g, '<code class="help-inline-code">$1</code>');
// Tables
html = html.replace(
/(?:^|\n)(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)+)/g,
(_m, headerRow: string, _sep: string, bodyRows: string) => {
const headers = headerRow
.split("|")
.slice(1, -1)
.map((c: string) => `<th>${c.trim()}</th>`)
.join("");
const rows = bodyRows
.trim()
.split("\n")
.map((row: string) => {
const cells = row
.split("|")
.slice(1, -1)
.map((c: string) => `<td>${c.trim()}</td>`)
.join("");
return `<tr>${cells}</tr>`;
})
.join("");
return `<table class="help-table"><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`;
},
);
// Blockquotes (> ...)
html = html.replace(/(?:^|\n)&gt; (.+)/g, '<blockquote class="help-blockquote">$1</blockquote>');
// Merge adjacent blockquotes
html = html.replace(/<\/blockquote>\s*<blockquote class="help-blockquote">/g, "<br/>");
// Horizontal rules
html = html.replace(/\n---\n/g, '<hr class="help-hr"/>');
// Headers with id attributes for anchor navigation (process from h4 down to h1)
html = html.replace(/^#### (.+)$/gm, (_m, title) => `<h4 class="help-h4" id="${slugify(title)}">${title}</h4>`);
html = html.replace(/^### (.+)$/gm, (_m, title) => `<h3 class="help-h3" id="${slugify(title)}">${title}</h3>`);
html = html.replace(/^## (.+)$/gm, (_m, title) => `<h2 class="help-h2" id="${slugify(title)}">${title}</h2>`);
html = html.replace(/^# (.+)$/gm, (_m, title) => `<h1 class="help-h1" id="${slugify(title)}">${title}</h1>`);
// Bold (**...**)
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
// Italic (*...*)
html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
// Markdown-style anchor links [text](#anchor)
html = html.replace(
/\[([^\]]+)\]\(#([^)]+)\)/g,
'<a class="help-link" href="#$2">$1</a>',
);
// Markdown-style external links [text](url)
html = html.replace(
/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
'<a class="help-link" href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
);
// Unordered list items (- ...)
// Group consecutive list items
html = html.replace(/((?:^|\n)- .+(?:\n- .+)*)/g, (block) => {
const items = block
.trim()
.split("\n")
.map((line) => `<li>${line.replace(/^- /, "")}</li>`)
.join("");
return `<ul class="help-ul">${items}</ul>`;
});
// Ordered list items (1. ...)
html = html.replace(/((?:^|\n)\d+\. .+(?:\n\d+\. .+)*)/g, (block) => {
const items = block
.trim()
.split("\n")
.map((line) => `<li>${line.replace(/^\d+\. /, "")}</li>`)
.join("");
return `<ol class="help-ol">${items}</ol>`;
});
// Links - convert bare URLs to clickable links (skip already-wrapped URLs)
html = html.replace(
/(?<!="|'>)(https?:\/\/[^\s<)]+)/g,
'<a class="help-link" href="$1" target="_blank" rel="noopener noreferrer">$1</a>',
);
// Wrap remaining loose text lines in paragraphs
// Split by double newlines for paragraph breaks
const blocks = html.split(/\n\n+/);
html = blocks
.map((block) => {
const trimmed = block.trim();
if (!trimmed) return "";
// Don't wrap blocks that are already HTML elements
if (
/^<(h[1-4]|ul|ol|pre|table|blockquote|hr)/.test(trimmed)
) {
return trimmed;
}
// Wrap in paragraph, replacing single newlines with <br/>
return `<p class="help-p">${trimmed.replace(/\n/g, "<br/>")}</p>`;
})
.join("\n");
return html;
}
export default function HelpDialog({ onClose }: Props) {
const overlayRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [markdown, setMarkdown] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
useEffect(() => {
getHelpContent()
.then(setMarkdown)
.catch((e) => setError(String(e)));
}, []);
const handleOverlayClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === overlayRef.current) onClose();
},
[onClose],
);
// Handle anchor link clicks to scroll within the dialog
const handleContentClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
const anchor = target.closest("a");
if (!anchor) return;
const href = anchor.getAttribute("href");
if (!href || !href.startsWith("#")) return;
e.preventDefault();
const el = contentRef.current?.querySelector(href);
if (el) el.scrollIntoView({ behavior: "smooth" });
}, []);
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg shadow-xl w-[48rem] max-w-[90vw] max-h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)] flex-shrink-0">
<h2 className="text-lg font-semibold">How to Use Triple-C</h2>
<button
onClick={onClose}
className="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
>
Close
</button>
</div>
{/* Scrollable content */}
<div
ref={contentRef}
onClick={handleContentClick}
className="flex-1 overflow-y-auto px-6 py-4 help-content"
>
{error && (
<p className="text-[var(--error)] text-sm">Failed to load help content: {error}</p>
)}
{!markdown && !error && (
<p className="text-[var(--text-secondary)] text-sm">Loading...</p>
)}
{markdown && (
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(markdown) }} />
)}
</div>
</div>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { useAppState } from "../../store/appState";
import { useSettings } from "../../hooks/useSettings";
import UpdateDialog from "../settings/UpdateDialog";
import ImageUpdateDialog from "../settings/ImageUpdateDialog";
import HelpDialog from "./HelpDialog";
export default function TopBar() {
const { dockerAvailable, imageExists, updateInfo, imageUpdateInfo, appVersion, setUpdateInfo, setImageUpdateInfo } = useAppState(
@@ -21,6 +22,7 @@ export default function TopBar() {
const { appSettings, saveSettings } = useSettings();
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
const [showImageUpdateDialog, setShowImageUpdateDialog] = useState(false);
const [showHelpDialog, setShowHelpDialog] = useState(false);
const handleDismiss = async () => {
if (appSettings && updateInfo) {
@@ -70,6 +72,13 @@ export default function TopBar() {
)}
<StatusDot ok={dockerAvailable === true} label="Docker" />
<StatusDot ok={imageExists === true} label="Image" />
<button
onClick={() => setShowHelpDialog(true)}
title="Help"
className="ml-1 w-5 h-5 flex items-center justify-center rounded-full border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] transition-colors text-xs font-semibold leading-none"
>
?
</button>
</div>
</div>
{showUpdateDialog && updateInfo && (
@@ -87,6 +96,9 @@ export default function TopBar() {
onClose={() => setShowImageUpdateDialog(false)}
/>
)}
{showHelpDialog && (
<HelpDialog onClose={() => setShowHelpDialog(false)} />
)}
</>
);
}

View File

@@ -12,6 +12,7 @@ import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
import ContainerProgressModal from "./ContainerProgressModal";
import FileManagerModal from "./FileManagerModal";
import ConfirmRemoveModal from "./ConfirmRemoveModal";
import Tooltip from "../ui/Tooltip";
interface Props {
project: Project;
@@ -448,7 +449,7 @@ export default function ProjectCard({ project }: Props) {
<div className="mt-2 ml-4 space-y-2 min-w-0 overflow-hidden">
{/* Backend selector */}
<div className="flex items-center gap-1 text-xs">
<span className="text-[var(--text-secondary)] mr-1">Backend:</span>
<span className="text-[var(--text-secondary)] mr-1">Backend:<Tooltip text="Choose the AI model provider for this project. Anthropic: Connect directly to Claude via OAuth login (run 'claude login' in terminal). Bedrock: Route through AWS Bedrock using your AWS credentials. Ollama: Use locally-hosted open-source models (Llama, Mistral, etc.) via an Ollama server. LiteLLM: Connect through a LiteLLM proxy gateway to access 100+ model providers (OpenAI, Azure, Gemini, etc.)." /></span>
<select
value={project.backend}
onChange={(e) => { e.stopPropagation(); handleBackendChange(e.target.value as Backend); }}
@@ -609,7 +610,7 @@ export default function ProjectCard({ project }: Props) {
{/* SSH Key */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory<Tooltip text="Path to your .ssh directory. Mounted into the container so Claude can authenticate with Git remotes over SSH." /></label>
<div className="flex gap-1">
<input
value={sshKeyPath}
@@ -631,7 +632,7 @@ export default function ProjectCard({ project }: Props) {
{/* Git Name */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name<Tooltip text="Sets git user.name inside the container for commit authorship." /></label>
<input
value={gitName}
onChange={(e) => setGitName(e.target.value)}
@@ -644,7 +645,7 @@ export default function ProjectCard({ project }: Props) {
{/* Git Email */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email<Tooltip text="Sets git user.email inside the container for commit authorship." /></label>
<input
value={gitEmail}
onChange={(e) => setGitEmail(e.target.value)}
@@ -657,7 +658,7 @@ export default function ProjectCard({ project }: Props) {
{/* Git Token (HTTPS) */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token<Tooltip text="A personal access token (e.g. GitHub PAT) for HTTPS git operations inside the container." /></label>
<input
type="password"
value={gitToken}
@@ -671,7 +672,7 @@ export default function ProjectCard({ project }: Props) {
{/* Docker access toggle */}
<div className="flex items-center gap-2">
<label className="text-xs text-[var(--text-secondary)]">Allow container spawning</label>
<label className="text-xs text-[var(--text-secondary)]">Allow container spawning<Tooltip text="Mounts the Docker socket so Claude can build and run Docker containers from inside the sandbox." /></label>
<button
onClick={async () => {
try { await update({ ...project, allow_docker_access: !project.allow_docker_access }); } catch (err) {
@@ -691,7 +692,7 @@ export default function ProjectCard({ project }: Props) {
{/* Mission Control toggle */}
<div className="flex items-center gap-2">
<label className="text-xs text-[var(--text-secondary)]">Mission Control</label>
<label className="text-xs text-[var(--text-secondary)]">Mission Control<Tooltip text="Enables a web dashboard for monitoring and managing Claude sessions remotely." /></label>
<button
onClick={async () => {
try {
@@ -714,7 +715,7 @@ export default function ProjectCard({ project }: Props) {
{/* Environment Variables */}
<div className="flex items-center justify-between">
<label className="text-xs text-[var(--text-secondary)]">
Environment Variables{envVars.length > 0 && ` (${envVars.length})`}
Environment Variables{envVars.length > 0 && ` (${envVars.length})`}<Tooltip text="Custom env vars injected into this project's container. Useful for API keys or tool configuration." />
</label>
<button
onClick={() => setShowEnvVarsModal(true)}
@@ -727,7 +728,7 @@ export default function ProjectCard({ project }: Props) {
{/* Port Mappings */}
<div className="flex items-center justify-between">
<label className="text-xs text-[var(--text-secondary)]">
Port Mappings{portMappings.length > 0 && ` (${portMappings.length})`}
Port Mappings{portMappings.length > 0 && ` (${portMappings.length})`}<Tooltip text="Map container ports to host ports so you can access dev servers running inside the container." />
</label>
<button
onClick={() => setShowPortMappingsModal(true)}
@@ -740,7 +741,7 @@ export default function ProjectCard({ project }: Props) {
{/* Claude Instructions */}
<div className="flex items-center justify-between">
<label className="text-xs text-[var(--text-secondary)]">
Claude Instructions{claudeInstructions ? " (set)" : ""}
Claude Instructions{claudeInstructions ? " (set)" : ""}<Tooltip text="Project-specific instructions written to CLAUDE.md. Guides Claude's behavior for this project." />
</label>
<button
onClick={() => setShowClaudeInstructionsModal(true)}
@@ -753,7 +754,7 @@ export default function ProjectCard({ project }: Props) {
{/* MCP Servers */}
{mcpServers.length > 0 && (
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-1">MCP Servers</label>
<label className="block text-xs text-[var(--text-secondary)] mb-1">MCP Servers<Tooltip text="Model Context Protocol servers give Claude access to external tools and data sources." /></label>
<div className="space-y-1">
{mcpServers.map((server) => {
const enabled = project.enabled_mcp_servers.includes(server.id);
@@ -819,7 +820,7 @@ export default function ProjectCard({ project }: Props) {
{/* AWS Region (always shown) */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region<Tooltip text="The AWS region where your Bedrock endpoint is available (e.g. us-east-1)." /></label>
<input
value={bedrockRegion}
onChange={(e) => setBedrockRegion(e.target.value)}
@@ -834,7 +835,7 @@ export default function ProjectCard({ project }: Props) {
{bc.auth_method === "static_credentials" && (
<>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID<Tooltip text="Your AWS IAM access key ID for Bedrock API authentication." /></label>
<input
value={bedrockAccessKeyId}
onChange={(e) => setBedrockAccessKeyId(e.target.value)}
@@ -845,7 +846,7 @@ export default function ProjectCard({ project }: Props) {
/>
</div>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Secret Access Key</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Secret Access Key<Tooltip text="Your AWS IAM secret key. Stored locally and injected as an env var into the container." /></label>
<input
type="password"
value={bedrockSecretKey}
@@ -856,7 +857,7 @@ export default function ProjectCard({ project }: Props) {
/>
</div>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Session Token (optional)</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Session Token (optional)<Tooltip text="Temporary session token for assumed-role or MFA-based AWS credentials." /></label>
<input
type="password"
value={bedrockSessionToken}
@@ -872,7 +873,7 @@ export default function ProjectCard({ project }: Props) {
{/* Profile field */}
{bc.auth_method === "profile" && (
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile<Tooltip text="Named profile from your AWS config/credentials files (e.g. 'default' or 'prod')." /></label>
<input
value={bedrockProfile}
onChange={(e) => setBedrockProfile(e.target.value)}
@@ -887,7 +888,7 @@ export default function ProjectCard({ project }: Props) {
{/* Bearer token field */}
{bc.auth_method === "bearer_token" && (
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Bearer Token</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Bearer Token<Tooltip text="An SSO or identity-center bearer token for Bedrock authentication." /></label>
<input
type="password"
value={bedrockBearerToken}
@@ -901,7 +902,7 @@ export default function ProjectCard({ project }: Props) {
{/* Model override */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)<Tooltip text="Override the default Bedrock model. Leave blank to use Claude's default." /></label>
<input
value={bedrockModelId}
onChange={(e) => setBedrockModelId(e.target.value)}
@@ -926,7 +927,7 @@ export default function ProjectCard({ project }: Props) {
</p>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Base URL</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Base URL<Tooltip text="URL of your Ollama server. Use host.docker.internal to reach the host machine from inside the container." /></label>
<input
value={ollamaBaseUrl}
onChange={(e) => setOllamaBaseUrl(e.target.value)}
@@ -941,7 +942,7 @@ export default function ProjectCard({ project }: Props) {
</div>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model (optional)</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model (required)<Tooltip text="Ollama model name to use (e.g. qwen3.5:27b). The model must be pulled in Ollama before starting the container." /></label>
<input
value={ollamaModelId}
onChange={(e) => setOllamaModelId(e.target.value)}
@@ -966,7 +967,7 @@ export default function ProjectCard({ project }: Props) {
</p>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Base URL</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Base URL<Tooltip text="URL of your LiteLLM proxy server. Use host.docker.internal for a locally running proxy." /></label>
<input
value={litellmBaseUrl}
onChange={(e) => setLitellmBaseUrl(e.target.value)}
@@ -981,7 +982,7 @@ export default function ProjectCard({ project }: Props) {
</div>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">API Key</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">API Key<Tooltip text="Authentication key for your LiteLLM proxy, if required." /></label>
<input
type="password"
value={litellmApiKey}
@@ -994,7 +995,7 @@ export default function ProjectCard({ project }: Props) {
</div>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model (optional)</label>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model (optional)<Tooltip text="Model identifier as configured in your LiteLLM proxy (e.g. gpt-4o, gemini-pro)." /></label>
<input
value={litellmModelId}
onChange={(e) => setLitellmModelId(e.target.value)}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { useSettings } from "../../hooks/useSettings";
import * as commands from "../../lib/tauri-commands";
import Tooltip from "../ui/Tooltip";
export default function AwsSettings() {
const { appSettings, saveSettings } = useSettings();
@@ -56,7 +57,7 @@ export default function AwsSettings() {
{/* AWS Config Path */}
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">AWS Config Path</span>
<span className="text-[var(--text-secondary)] text-xs block mb-1">AWS Config Path<Tooltip text="Path to your AWS config/credentials directory. Mounted into containers for Bedrock auth." /></span>
<div className="flex gap-2">
<input
type="text"
@@ -80,7 +81,7 @@ export default function AwsSettings() {
{/* AWS Profile */}
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Profile</span>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Profile<Tooltip text="AWS named profile to use by default. Per-project settings can override this." /></span>
<select
value={globalAws.aws_profile ?? ""}
onChange={(e) => handleChange("aws_profile", e.target.value)}
@@ -95,7 +96,7 @@ export default function AwsSettings() {
{/* AWS Region */}
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Region</span>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Region<Tooltip text="Default AWS region for Bedrock API calls (e.g. us-east-1). Can be overridden per project." /></span>
<input
type="text"
value={globalAws.aws_region ?? ""}

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import { useDocker } from "../../hooks/useDocker";
import { useSettings } from "../../hooks/useSettings";
import type { ImageSource } from "../../lib/types";
import Tooltip from "../ui/Tooltip";
const REGISTRY_IMAGE = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest";
@@ -87,7 +88,7 @@ export default function DockerSettings() {
{/* Image Source Selector */}
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1.5">Image Source</span>
<span className="text-[var(--text-secondary)] text-xs block mb-1.5">Image Source<Tooltip text="Registry pulls the pre-built image. Local Build compiles from the bundled Dockerfile. Custom lets you specify any image." /></span>
<div className="flex gap-1">
{IMAGE_SOURCE_OPTIONS.map((opt) => (
<button
@@ -109,7 +110,7 @@ export default function DockerSettings() {
{/* Custom image input */}
{imageSource === "custom" && (
<div>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Custom Image</span>
<span className="text-[var(--text-secondary)] text-xs block mb-1">Custom Image<Tooltip text="Full image name including registry and tag (e.g. myregistry.com/image:tag)." /></span>
<input
type="text"
value={customInput}

View File

@@ -7,6 +7,7 @@ import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
import EnvVarsModal from "../projects/EnvVarsModal";
import { detectHostTimezone } from "../../lib/tauri-commands";
import type { EnvVar } from "../../lib/types";
import Tooltip from "../ui/Tooltip";
export default function SettingsPanel() {
const { appSettings, saveSettings } = useSettings();
@@ -59,7 +60,7 @@ export default function SettingsPanel() {
{/* Container Timezone */}
<div>
<label className="block text-sm font-medium mb-1">Container Timezone</label>
<label className="block text-sm font-medium mb-1">Container Timezone<Tooltip text="Sets the timezone inside containers. Affects scheduled task timing and log timestamps." /></label>
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
Timezone for containers affects scheduled task timing (IANA format, e.g. America/New_York)
</p>
@@ -79,7 +80,7 @@ export default function SettingsPanel() {
{/* Global Claude Instructions */}
<div>
<label className="block text-sm font-medium mb-1">Claude Instructions</label>
<label className="block text-sm font-medium mb-1">Claude Instructions<Tooltip text="Global instructions applied to all projects. Written to ~/.claude/CLAUDE.md in every container." /></label>
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
Global instructions applied to all projects (written to ~/.claude/CLAUDE.md in containers)
</p>
@@ -98,7 +99,7 @@ export default function SettingsPanel() {
{/* Global Environment Variables */}
<div>
<label className="block text-sm font-medium mb-1">Global Environment Variables</label>
<label className="block text-sm font-medium mb-1">Global Environment Variables<Tooltip text="Env vars injected into all containers. Per-project vars with the same key take precedence." /></label>
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
Applied to all project containers. Per-project variables override global ones with the same key.
</p>
@@ -117,7 +118,7 @@ export default function SettingsPanel() {
{/* Updates section */}
<div>
<label className="block text-sm font-medium mb-2">Updates</label>
<label className="block text-sm font-medium mb-2">Updates<Tooltip text="Check for new versions of the Triple-C app and container image." /></label>
<div className="space-y-2">
{appVersion && (
<p className="text-xs text-[var(--text-secondary)]">

View File

@@ -80,6 +80,22 @@ export default function TerminalView({ sessionId, active }: Props) {
term.open(containerRef.current);
// Ctrl+Shift+C copies selected terminal text to clipboard.
// This prevents the keystroke from reaching the container (where
// Ctrl+C would send SIGINT and cancel running work).
term.attachCustomKeyEventHandler((event) => {
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "C") {
const sel = term.getSelection();
if (sel) {
navigator.clipboard.writeText(sel).catch((e) =>
console.error("Ctrl+Shift+C clipboard write failed:", e),
);
}
return false; // prevent xterm from processing this key
}
return true;
});
// WebGL addon is loaded/disposed dynamically in the active effect
// to avoid exhausting the browser's limited WebGL context pool.

View File

@@ -0,0 +1,78 @@
import { useState, useRef, useLayoutEffect, type ReactNode } from "react";
import { createPortal } from "react-dom";
interface TooltipProps {
text: string;
children?: ReactNode;
}
/**
* A small circled question-mark icon that shows a tooltip on hover.
* Uses a portal to render at `document.body` so the tooltip is never
* clipped by ancestor `overflow: hidden` containers.
*/
export default function Tooltip({ text, children }: TooltipProps) {
const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const [, setPlacement] = useState<"top" | "bottom">("top");
const triggerRef = useRef<HTMLSpanElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (!visible || !triggerRef.current || !tooltipRef.current) return;
const trigger = triggerRef.current.getBoundingClientRect();
const tooltip = tooltipRef.current.getBoundingClientRect();
const gap = 6;
// Vertical: prefer above, fall back to below
const above = trigger.top - tooltip.height - gap >= 4;
const pos = above ? "top" : "bottom";
setPlacement(pos);
const top =
pos === "top"
? trigger.top - tooltip.height - gap
: trigger.bottom + gap;
// Horizontal: center on trigger, clamp to viewport
let left = trigger.left + trigger.width / 2 - tooltip.width / 2;
left = Math.max(4, Math.min(left, window.innerWidth - tooltip.width - 4));
setCoords({ top, left });
}, [visible]);
return (
<span
ref={triggerRef}
className="inline-flex items-center ml-1"
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
>
{children ?? (
<span
className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-[var(--text-secondary)] text-[var(--text-secondary)] text-[9px] leading-none cursor-help select-none hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
aria-label="Help"
>
?
</span>
)}
{visible &&
createPortal(
<div
ref={tooltipRef}
style={{
position: "fixed",
top: coords.top,
left: coords.left,
zIndex: 9999,
}}
className={`px-2.5 py-1.5 text-[11px] leading-snug text-[var(--text-primary)] bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded shadow-lg whitespace-normal max-w-[280px] w-max pointer-events-none`}
>
{text}
</div>,
document.body
)}
</span>
);
}

View File

@@ -53,3 +53,135 @@ body {
to { opacity: 1; transform: translate(-50%, 0); }
}
.animate-slide-down { animation: slide-down 0.2s ease-out; }
/* Help dialog content styles */
.help-content {
font-size: 0.8125rem;
line-height: 1.6;
color: var(--text-primary);
}
.help-content .help-h1 {
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 1rem 0;
color: var(--text-primary);
}
.help-content .help-h2 {
font-size: 1.15rem;
font-weight: 600;
margin: 1.5rem 0 0.75rem 0;
padding-bottom: 0.375rem;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
}
.help-content .help-h3 {
font-size: 0.95rem;
font-weight: 600;
margin: 1.25rem 0 0.5rem 0;
color: var(--text-primary);
}
.help-content .help-h4 {
font-size: 0.875rem;
font-weight: 600;
margin: 1rem 0 0.375rem 0;
color: var(--text-secondary);
}
.help-content .help-p {
margin: 0.5rem 0;
}
.help-content .help-ul,
.help-content .help-ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.help-content .help-ul {
list-style-type: disc;
}
.help-content .help-ol {
list-style-type: decimal;
}
.help-content .help-ul li,
.help-content .help-ol li {
margin: 0.25rem 0;
}
.help-content .help-code-block {
display: block;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0.75rem 1rem;
margin: 0.5rem 0;
overflow-x: auto;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.75rem;
line-height: 1.5;
white-space: pre;
}
.help-content .help-inline-code {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 3px;
padding: 0.125rem 0.375rem;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.75rem;
}
.help-content .help-table {
width: 100%;
border-collapse: collapse;
margin: 0.5rem 0;
font-size: 0.75rem;
}
.help-content .help-table th,
.help-content .help-table td {
border: 1px solid var(--border-color);
padding: 0.375rem 0.625rem;
text-align: left;
}
.help-content .help-table th {
background: var(--bg-tertiary);
font-weight: 600;
}
.help-content .help-table td {
background: var(--bg-primary);
}
.help-content .help-blockquote {
border-left: 3px solid var(--accent);
background: var(--bg-primary);
margin: 0.5rem 0;
padding: 0.5rem 0.75rem;
border-radius: 0 4px 4px 0;
color: var(--text-secondary);
font-size: 0.75rem;
}
.help-content .help-hr {
border: none;
border-top: 1px solid var(--border-color);
margin: 1.5rem 0;
}
.help-content .help-link {
color: var(--accent);
text-decoration: none;
}
.help-content .help-link:hover {
color: var(--accent-hover);
text-decoration: underline;
}

View File

@@ -85,3 +85,6 @@ export const checkForUpdates = () =>
invoke<UpdateInfo | null>("check_for_updates");
export const checkImageUpdate = () =>
invoke<ImageUpdateInfo | null>("check_image_update");
// Help
export const getHelpContent = () => invoke<string>("get_help_content");