Compare commits
6 Commits
v0.2.2
...
v0.2.8-mac
| Author | SHA1 | Date | |
|---|---|---|---|
| 4732feb33e | |||
| 5977024953 | |||
| 27007b90e3 | |||
| 38e65619e9 | |||
| d2c1c2108a | |||
| cc163e6650 |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
60
app/src-tauri/src/commands/help_commands.rs
Normal file
60
app/src-tauri/src/commands/help_commands.rs
Normal 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))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
218
app/src/components/layout/HelpDialog.tsx
Normal file
218
app/src/components/layout/HelpDialog.tsx
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
// 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)> (.+)/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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 ?? ""}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
78
app/src/components/ui/Tooltip.tsx
Normal file
78
app/src/components/ui/Tooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user