Compare commits

..

4 Commits

Author SHA1 Message Date
3935104cb5 Fix xterm scroll jump and viewport desync during long output
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m24s
Build App / build-windows (push) Successful in 3m30s
Build App / build-linux (push) Successful in 4m47s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Auto-scroll viewport on new output when user is at bottom, debounce
scroll state updates to reduce re-renders, preserve scroll position
across resize reflows, and fix "Jump to Current" button by re-fitting
the terminal to clear viewport desync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-13 12:59:19 -07:00
b17c759bd6 Fix remaining repo.anhonesthost.net references in user-facing code
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 4m0s
Build App / build-linux (push) Successful in 4m35s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 14s
- help_commands.rs: fetch HOW-TO-USE.md from GitHub raw instead of Gitea
- DockerSettings.tsx: display GHCR image address in settings UI
- HOW-TO-USE.md: update registry description to ghcr.io

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:31:46 -07:00
bab1df1c57 Switch distribution from Gitea to GitHub/GHCR
All checks were successful
Build App / compute-version (push) Successful in 6s
Build Container / build-container (push) Successful in 1m44s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m30s
Build App / build-linux (push) Successful in 4m52s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Work VPN blocks repo.anhonesthost.net, breaking update checks and image
pulls. Move all user-facing distribution to GitHub (releases API) and
GHCR (container images) while keeping Gitea as the source of truth for
development and CI.

- CI: push container images to GHCR alongside Gitea registry
- App updates: switch releases API to api.github.com, filter by asset
  filename instead of tag suffix for unified releases
- Image updates: switch registry to ghcr.io with anonymous token auth
- Container pull: point REGISTRY_IMAGE to ghcr.io/shadowdao/triple-c-sandbox
- Rename GiteaRelease/GiteaAsset structs to GitHubRelease/GitHubAsset

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:55:41 -07:00
b952b8e8de Add per-project full permissions toggle for --dangerously-skip-permissions
All checks were successful
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m19s
Build App / build-windows (push) Successful in 2m35s
Build App / build-linux (push) Successful in 4m43s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 11s
New projects default to standard permission mode (Claude asks before acting).
Existing projects default to full permissions ON, preserving current behavior.
UI toggle uses red/caution styling to highlight the security implications.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:58:13 -07:00
13 changed files with 181 additions and 55 deletions

View File

@@ -36,6 +36,13 @@ jobs:
username: ${{ gitea.actor }} username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }} password: ${{ secrets.REGISTRY_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: shadowdao
password: ${{ secrets.GH_PAT }}
- name: Build and push container image - name: Build and push container image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
@@ -46,5 +53,7 @@ jobs:
tags: | tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
ghcr.io/shadowdao/triple-c-sandbox:latest
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max

View File

@@ -70,7 +70,7 @@ Choose an **Image Source**:
| Source | Description | When to Use | | Source | Description | When to Use |
|--------|-------------|-------------| |--------|-------------|-------------|
| **Registry** | Pulls the pre-built image from `repo.anhonesthost.net` | Fastest setup — recommended for most users | | **Registry** | Pulls the pre-built image from `ghcr.io` | Fastest setup — recommended for most users |
| **Local Build** | Builds the image locally from the embedded Dockerfile | If you can't reach the registry, or want a custom build | | **Local Build** | Builds the image locally from the embedded Dockerfile | If you can't reach the registry, or want a custom build |
| **Custom** | Use any Docker image you specify | Advanced — bring your own sandbox image | | **Custom** | Use any Docker image you specify | Advanced — bring your own sandbox image |
@@ -92,7 +92,7 @@ Select your project in the sidebar and click **Start**. A progress modal appears
Click the **Terminal** button to open an interactive terminal session. A new tab appears in the top bar and an xterm.js terminal loads in the main area. Click the **Terminal** button to open an interactive terminal session. A new tab appears in the top bar and an xterm.js terminal loads in the main area.
Claude Code launches automatically with `--dangerously-skip-permissions` inside the sandboxed container. Claude Code launches automatically. By default, it runs in standard permission mode and will ask for your approval before executing commands or editing files. To enable auto-approval of all actions within the sandbox, enable **Full Permissions** in the project configuration.
### 5. Authenticate ### 5. Authenticate
@@ -236,6 +236,18 @@ Available skills include `/mission`, `/flight`, `/leg`, `/agentic-workflow`, `/f
> This setting can only be changed when the container is stopped. Toggling it triggers a container recreation on the next start. > This setting can only be changed when the container is stopped. Toggling it triggers a container recreation on the next start.
### Full Permissions
Toggle **Full Permissions** to allow Claude Code to run with `--dangerously-skip-permissions` inside the container. This is **off by default**.
When **enabled**, Claude auto-approves all tool calls (file edits, shell commands, etc.) without prompting you. This is the fastest workflow since you won't be interrupted for approvals, and the Docker container provides isolation.
When **disabled** (default), Claude prompts you for approval before executing each action, giving you fine-grained control over what it does.
> **CAUTION:** Enabling full permissions means Claude can execute any command inside the container without asking. While the container sandbox limits the blast radius, make sure you understand the implications — especially if the container has Docker socket access or network connectivity.
> This setting can only be changed when the container is stopped. It takes effect the next time you open a terminal session.
### Environment Variables ### Environment Variables
Click **Edit** to open the environment variables modal. Add key-value pairs that will be injected into the container. Per-project variables override global variables with the same key. Click **Edit** to open the environment variables modal. Add key-value pairs that will be injected into the container. Per-project variables override global variables with the same key.

View File

@@ -1,6 +1,6 @@
# Triple-C (Claude-Code-Container) # Triple-C (Claude-Code-Container)
Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. When running with `--dangerously-skip-permissions`, Claude only has access to the files and projects you explicitly provide to it. Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. Each project can optionally enable full permissions mode (`--dangerously-skip-permissions`), giving Claude unrestricted access within the sandbox.
## Architecture ## Architecture

View File

@@ -2,7 +2,7 @@ use std::sync::OnceLock;
use tokio::sync::Mutex; use tokio::sync::Mutex;
const HELP_URL: &str = const HELP_URL: &str =
"https://repo.anhonesthost.net/cybercovellc/triple-c/raw/branch/main/HOW-TO-USE.md"; "https://raw.githubusercontent.com/shadowdao/triple-c/main/HOW-TO-USE.md";
const EMBEDDED_HELP: &str = include_str!("../../../../HOW-TO-USE.md"); const EMBEDDED_HELP: &str = include_str!("../../../../HOW-TO-USE.md");

View File

@@ -17,10 +17,11 @@ fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> {
.unwrap_or(false); .unwrap_or(false);
if !is_bedrock_profile { if !is_bedrock_profile {
return vec![ let mut cmd = vec!["claude".to_string()];
"claude".to_string(), if project.full_permissions {
"--dangerously-skip-permissions".to_string(), cmd.push("--dangerously-skip-permissions".to_string());
]; }
return cmd;
} }
// Resolve AWS profile: project-level → global settings → "default" // Resolve AWS profile: project-level → global settings → "default"
@@ -33,6 +34,12 @@ fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> {
// Build a bash wrapper that validates credentials, re-auths if needed, // Build a bash wrapper that validates credentials, re-auths if needed,
// then exec's into claude. // then exec's into claude.
let claude_cmd = if project.full_permissions {
"exec claude --dangerously-skip-permissions"
} else {
"exec claude"
};
let script = format!( let script = format!(
r#" r#"
echo "Validating AWS session for profile '{profile}'..." echo "Validating AWS session for profile '{profile}'..."
@@ -58,9 +65,10 @@ else
echo "" echo ""
fi fi
fi fi
exec claude --dangerously-skip-permissions {claude_cmd}
"#, "#,
profile = profile profile = profile,
claude_cmd = claude_cmd
); );
vec![ vec![

View File

@@ -1,15 +1,20 @@
use serde::Deserialize;
use tauri::State; use tauri::State;
use crate::docker; use crate::docker;
use crate::models::{container_config, GiteaRelease, ImageUpdateInfo, ReleaseAsset, UpdateInfo}; use crate::models::{container_config, GitHubRelease, ImageUpdateInfo, ReleaseAsset, UpdateInfo};
use crate::AppState; use crate::AppState;
const RELEASES_URL: &str = const RELEASES_URL: &str =
"https://repo.anhonesthost.net/api/v1/repos/cybercovellc/triple-c/releases"; "https://api.github.com/repos/shadowdao/triple-c/releases";
/// Gitea container-registry tag object (v2 manifest). /// GHCR container-registry API base (OCI distribution spec).
const REGISTRY_API_BASE: &str = const REGISTRY_API_BASE: &str =
"https://repo.anhonesthost.net/v2/cybercovellc/triple-c/triple-c-sandbox"; "https://ghcr.io/v2/shadowdao/triple-c-sandbox";
/// GHCR token endpoint for anonymous pull access.
const GHCR_TOKEN_URL: &str =
"https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull";
#[tauri::command] #[tauri::command]
pub fn get_app_version() -> String { pub fn get_app_version() -> String {
@@ -23,9 +28,10 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
.build() .build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?; .map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let releases: Vec<GiteaRelease> = client let releases: Vec<GitHubRelease> = client
.get(RELEASES_URL) .get(RELEASES_URL)
.header("Accept", "application/json") .header("Accept", "application/json")
.header("User-Agent", "triple-c-updater")
.send() .send()
.await .await
.map_err(|e| format!("Failed to fetch releases: {}", e))? .map_err(|e| format!("Failed to fetch releases: {}", e))?
@@ -36,30 +42,27 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
let current_version = env!("CARGO_PKG_VERSION"); let current_version = env!("CARGO_PKG_VERSION");
let current_semver = parse_semver(current_version).unwrap_or((0, 0, 0)); let current_semver = parse_semver(current_version).unwrap_or((0, 0, 0));
// Determine platform suffix for tag filtering // Determine platform-specific asset extensions
let platform_suffix: &str = if cfg!(target_os = "windows") { let platform_extensions: &[&str] = if cfg!(target_os = "windows") {
"-win" &[".msi", ".exe"]
} else if cfg!(target_os = "macos") { } else if cfg!(target_os = "macos") {
"-mac" &[".dmg", ".app.tar.gz"]
} else { } else {
"" // Linux uses bare tags (no suffix) &[".AppImage", ".deb", ".rpm"]
}; };
// Filter releases by platform tag suffix // Filter releases that have at least one asset matching the current platform
let platform_releases: Vec<&GiteaRelease> = releases let platform_releases: Vec<&GitHubRelease> = releases
.iter() .iter()
.filter(|r| { .filter(|r| {
if platform_suffix.is_empty() { r.assets.iter().any(|a| {
// Linux: bare tag only (no -win, no -mac) platform_extensions.iter().any(|ext| a.name.ends_with(ext))
!r.tag_name.ends_with("-win") && !r.tag_name.ends_with("-mac") })
} else {
r.tag_name.ends_with(platform_suffix)
}
}) })
.collect(); .collect();
// Find the latest release with a higher semver version // Find the latest release with a higher semver version
let mut best: Option<(&GiteaRelease, (u32, u32, u32))> = None; let mut best: Option<(&GitHubRelease, (u32, u32, u32))> = None;
for release in &platform_releases { for release in &platform_releases {
if let Some(ver) = parse_semver_from_tag(&release.tag_name) { if let Some(ver) = parse_semver_from_tag(&release.tag_name) {
if ver > current_semver { if ver > current_semver {
@@ -72,9 +75,13 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
match best { match best {
Some((release, _)) => { Some((release, _)) => {
// Only include assets matching the current platform
let assets = release let assets = release
.assets .assets
.iter() .iter()
.filter(|a| {
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
})
.map(|a| ReleaseAsset { .map(|a| ReleaseAsset {
name: a.name.clone(), name: a.name.clone(),
browser_download_url: a.browser_download_url.clone(), browser_download_url: a.browser_download_url.clone(),
@@ -82,7 +89,6 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
}) })
.collect(); .collect();
// Reconstruct version string from tag
let version = extract_version_from_tag(&release.tag_name) let version = extract_version_from_tag(&release.tag_name)
.unwrap_or_else(|| release.tag_name.clone()); .unwrap_or_else(|| release.tag_name.clone());
@@ -113,17 +119,13 @@ fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
} }
} }
/// Parse semver from a tag like "v0.2.5", "v0.2.5-win", "v0.2.5-mac" -> (0, 2, 5) /// Parse semver from a tag like "v0.2.5" -> (0, 2, 5)
fn parse_semver_from_tag(tag: &str) -> Option<(u32, u32, u32)> { fn parse_semver_from_tag(tag: &str) -> Option<(u32, u32, u32)> {
let clean = tag.trim_start_matches('v'); let clean = tag.trim_start_matches('v');
// Remove platform suffix
let clean = clean.strip_suffix("-win")
.or_else(|| clean.strip_suffix("-mac"))
.unwrap_or(clean);
parse_semver(clean) parse_semver(clean)
} }
/// Extract a clean version string from a tag like "v0.2.5-win" -> "0.2.5" /// Extract a clean version string from a tag like "v0.2.5" -> "0.2.5"
fn extract_version_from_tag(tag: &str) -> Option<String> { fn extract_version_from_tag(tag: &str) -> Option<String> {
let (major, minor, patch) = parse_semver_from_tag(tag)?; let (major, minor, patch) = parse_semver_from_tag(tag)?;
Some(format!("{}.{}.{}", major, minor, patch)) Some(format!("{}.{}.{}", major, minor, patch))
@@ -152,7 +154,7 @@ pub async fn check_image_update(
// 1. Get local image digest via Docker // 1. Get local image digest via Docker
let local_digest = docker::get_local_image_digest(&image_name).await.ok().flatten(); let local_digest = docker::get_local_image_digest(&image_name).await.ok().flatten();
// 2. Get remote digest from the Gitea container registry (OCI distribution spec) // 2. Get remote digest from the GHCR container registry (OCI distribution spec)
let remote_digest = fetch_remote_digest("latest").await?; let remote_digest = fetch_remote_digest("latest").await?;
// No remote digest available — nothing to compare // No remote digest available — nothing to compare
@@ -176,25 +178,36 @@ pub async fn check_image_update(
})) }))
} }
/// Fetch the digest of a tag from the Gitea container registry using the /// Fetch the digest of a tag from GHCR using the OCI / Docker Registry HTTP API v2.
/// OCI / Docker Registry HTTP API v2.
/// ///
/// We issue a HEAD request to /v2/<repo>/manifests/<tag> and read the /// GHCR requires authentication even for public images, so we first obtain an
/// `Docker-Content-Digest` header that the registry returns. /// anonymous token, then issue a HEAD request to /v2/<repo>/manifests/<tag>
/// and read the `Docker-Content-Digest` header.
async fn fetch_remote_digest(tag: &str) -> Result<Option<String>, String> { async fn fetch_remote_digest(tag: &str) -> Result<Option<String>, String> {
let url = format!("{}/manifests/{}", REGISTRY_API_BASE, tag);
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15)) .timeout(std::time::Duration::from_secs(15))
.build() .build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?; .map_err(|e| format!("Failed to create HTTP client: {}", e))?;
// 1. Obtain anonymous bearer token from GHCR
let token = match fetch_ghcr_token(&client).await {
Ok(t) => t,
Err(e) => {
log::warn!("Failed to obtain GHCR token: {}", e);
return Ok(None);
}
};
// 2. HEAD the manifest with the token
let url = format!("{}/manifests/{}", REGISTRY_API_BASE, tag);
let response = client let response = client
.head(&url) .head(&url)
.header( .header(
"Accept", "Accept",
"application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json", "application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json",
) )
.header("Authorization", format!("Bearer {}", token))
.send() .send()
.await; .await;
@@ -221,3 +234,23 @@ async fn fetch_remote_digest(tag: &str) -> Result<Option<String>, String> {
} }
} }
} }
/// Fetch an anonymous bearer token from GHCR for pulling public images.
async fn fetch_ghcr_token(client: &reqwest::Client) -> Result<String, String> {
#[derive(Deserialize)]
struct TokenResponse {
token: String,
}
let resp: TokenResponse = client
.get(GHCR_TOKEN_URL)
.header("User-Agent", "triple-c-updater")
.send()
.await
.map_err(|e| format!("GHCR token request failed: {}", e))?
.json()
.await
.map_err(|e| format!("Failed to parse GHCR token response: {}", e))?;
Ok(resp.token)
}

View File

@@ -12,7 +12,7 @@ pub struct ContainerInfo {
pub const LOCAL_IMAGE_NAME: &str = "triple-c"; pub const LOCAL_IMAGE_NAME: &str = "triple-c";
pub const IMAGE_TAG: &str = "latest"; pub const IMAGE_TAG: &str = "latest";
pub const REGISTRY_IMAGE: &str = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest"; pub const REGISTRY_IMAGE: &str = "ghcr.io/shadowdao/triple-c-sandbox:latest";
pub fn local_build_image_name() -> String { pub fn local_build_image_name() -> String {
format!("{LOCAL_IMAGE_NAME}:{IMAGE_TAG}") format!("{LOCAL_IMAGE_NAME}:{IMAGE_TAG}")

View File

@@ -24,6 +24,10 @@ fn default_protocol() -> String {
"tcp".to_string() "tcp".to_string()
} }
fn default_full_permissions() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project { pub struct Project {
pub id: String, pub id: String,
@@ -40,6 +44,8 @@ pub struct Project {
pub allow_docker_access: bool, pub allow_docker_access: bool,
#[serde(default)] #[serde(default)]
pub mission_control_enabled: bool, pub mission_control_enabled: bool,
#[serde(default = "default_full_permissions")]
pub full_permissions: bool,
pub ssh_key_path: Option<String>, pub ssh_key_path: Option<String>,
#[serde(skip_serializing, default)] #[serde(skip_serializing, default)]
pub git_token: Option<String>, pub git_token: Option<String>,
@@ -162,6 +168,7 @@ impl Project {
openai_compatible_config: None, openai_compatible_config: None,
allow_docker_access: false, allow_docker_access: false,
mission_control_enabled: false, mission_control_enabled: false,
full_permissions: false,
ssh_key_path: None, ssh_key_path: None,
git_token: None, git_token: None,
git_user_name: None, git_user_name: None,

View File

@@ -18,19 +18,19 @@ pub struct ReleaseAsset {
pub size: u64, pub size: u64,
} }
/// Gitea API release response (internal). /// GitHub API release response (internal).
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct GiteaRelease { pub struct GitHubRelease {
pub tag_name: String, pub tag_name: String,
pub html_url: String, pub html_url: String,
pub body: String, pub body: String,
pub assets: Vec<GiteaAsset>, pub assets: Vec<GitHubAsset>,
pub published_at: String, pub published_at: String,
} }
/// Gitea API asset response (internal). /// GitHub API asset response (internal).
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct GiteaAsset { pub struct GitHubAsset {
pub name: String, pub name: String,
pub browser_download_url: String, pub browser_download_url: String,
pub size: u64, pub size: u64,

View File

@@ -712,6 +712,32 @@ export default function ProjectCard({ project }: Props) {
</button> </button>
</div> </div>
{/* Full Permissions toggle */}
<div className="flex items-center gap-2">
<label className="text-xs text-[var(--text-secondary)]">
Full Permissions
<span className="text-[var(--error)] font-semibold ml-1">(CAUTION)</span>
<Tooltip text="When enabled, Claude runs with --dangerously-skip-permissions and auto-approves all tool calls without prompting. Only enable this if you trust the sandboxed environment to contain all actions. When disabled, Claude will ask for your approval before running commands, editing files, etc." />
</label>
<button
onClick={async () => {
try {
await update({ ...project, full_permissions: !project.full_permissions });
} catch (err) {
console.error("Failed to update full permissions setting:", err);
}
}}
disabled={!isStopped}
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${
project.full_permissions
? "bg-[var(--error)] text-white"
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
}`}
>
{project.full_permissions ? "ON" : "OFF"}
</button>
</div>
{/* Environment Variables */} {/* Environment Variables */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-xs text-[var(--text-secondary)]"> <label className="text-xs text-[var(--text-secondary)]">

View File

@@ -4,7 +4,7 @@ import { useSettings } from "../../hooks/useSettings";
import type { ImageSource } from "../../lib/types"; import type { ImageSource } from "../../lib/types";
import Tooltip from "../ui/Tooltip"; import Tooltip from "../ui/Tooltip";
const REGISTRY_IMAGE = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest"; const REGISTRY_IMAGE = "ghcr.io/shadowdao/triple-c-sandbox:latest";
const IMAGE_SOURCE_OPTIONS: { value: ImageSource; label: string; description: string }[] = [ const IMAGE_SOURCE_OPTIONS: { value: ImageSource; label: string; description: string }[] = [
{ value: "registry", label: "Registry", description: "Pull from container registry" }, { value: "registry", label: "Registry", description: "Pull from container registry" },

View File

@@ -35,6 +35,7 @@ export default function TerminalView({ sessionId, active }: Props) {
const [detectedUrl, setDetectedUrl] = useState<string | null>(null); const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null); const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true); const [isAtBottom, setIsAtBottom] = useState(true);
const isAtBottomRef = useRef(true);
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
@@ -131,10 +132,19 @@ export default function TerminalView({ sessionId, active }: Props) {
sendInput(sessionId, data); sendInput(sessionId, data);
}); });
// Track scroll position to show "Jump to Current" button // Track scroll position to show "Jump to Current" button.
// Debounce state updates via rAF to avoid excessive re-renders during rapid output.
let scrollStateRafId: number | null = null;
const scrollDisposable = term.onScroll(() => { const scrollDisposable = term.onScroll(() => {
const buf = term.buffer.active; const buf = term.buffer.active;
setIsAtBottom(buf.viewportY >= buf.baseY); const atBottom = buf.viewportY >= buf.baseY;
isAtBottomRef.current = atBottom;
if (scrollStateRafId === null) {
scrollStateRafId = requestAnimationFrame(() => {
scrollStateRafId = null;
setIsAtBottom(isAtBottomRef.current);
});
}
}); });
// Track text selection to show copy hint in status bar // Track text selection to show copy hint in status bar
@@ -187,7 +197,15 @@ export default function TerminalView({ sessionId, active }: Props) {
const outputPromise = onOutput(sessionId, (data) => { const outputPromise = onOutput(sessionId, (data) => {
if (aborted) return; if (aborted) return;
term.write(data); const shouldFollow = isAtBottomRef.current;
term.write(data, () => {
// Keep viewport pinned to bottom when user hasn't scrolled up
if (shouldFollow) {
term.scrollToBottom();
isAtBottomRef.current = true;
setIsAtBottom(true);
}
});
detector.feed(data); detector.feed(data);
// Scan for SSO refresh marker in terminal output // Scan for SSO refresh marker in terminal output
@@ -229,8 +247,13 @@ export default function TerminalView({ sessionId, active }: Props) {
resizeRafId = requestAnimationFrame(() => { resizeRafId = requestAnimationFrame(() => {
resizeRafId = null; resizeRafId = null;
if (!containerRef.current || containerRef.current.offsetWidth === 0) return; if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
const wasAtBottom = isAtBottomRef.current;
fitAddon.fit(); fitAddon.fit();
resize(sessionId, term.cols, term.rows); resize(sessionId, term.cols, term.rows);
// Maintain scroll position after resize reflow
if (wasAtBottom) {
term.scrollToBottom();
}
}); });
}); });
resizeObserver.observe(containerRef.current); resizeObserver.observe(containerRef.current);
@@ -249,6 +272,7 @@ export default function TerminalView({ sessionId, active }: Props) {
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true }); containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
outputPromise.then((fn) => fn?.()); outputPromise.then((fn) => fn?.());
exitPromise.then((fn) => fn?.()); exitPromise.then((fn) => fn?.());
if (scrollStateRafId !== null) cancelAnimationFrame(scrollStateRafId);
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId); if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
resizeObserver.disconnect(); resizeObserver.disconnect();
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ } try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
@@ -314,8 +338,14 @@ export default function TerminalView({ sessionId, active }: Props) {
}, [detectedUrl]); }, [detectedUrl]);
const handleScrollToBottom = useCallback(() => { const handleScrollToBottom = useCallback(() => {
termRef.current?.scrollToBottom(); const term = termRef.current;
if (term) {
// Re-fit first to fix viewport desync (same thing a resize does)
fitRef.current?.fit();
term.scrollToBottom();
isAtBottomRef.current = true;
setIsAtBottom(true); setIsAtBottom(true);
}
}, []); }, []);
return ( return (

View File

@@ -26,6 +26,7 @@ export interface Project {
openai_compatible_config: OpenAiCompatibleConfig | null; openai_compatible_config: OpenAiCompatibleConfig | null;
allow_docker_access: boolean; allow_docker_access: boolean;
mission_control_enabled: boolean; mission_control_enabled: boolean;
full_permissions: boolean;
ssh_key_path: string | null; ssh_key_path: string | null;
git_token: string | null; git_token: string | null;
git_user_name: string | null; git_user_name: string | null;