Compare commits
7 Commits
v0.2.14-ma
...
v0.2.21-ma
| Author | SHA1 | Date | |
|---|---|---|---|
| 13038989b8 | |||
| b55de8d75e | |||
| 8512ca615d | |||
| ebae39026f | |||
| d34e8e2c6d | |||
| 3935104cb5 | |||
| b17c759bd6 |
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -704,6 +704,13 @@ pub async fn create_container(
|
|||||||
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string());
|
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string());
|
||||||
labels.insert("triple-c.mcp-fingerprint".to_string(), compute_mcp_fingerprint(mcp_servers));
|
labels.insert("triple-c.mcp-fingerprint".to_string(), compute_mcp_fingerprint(mcp_servers));
|
||||||
labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string());
|
labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string());
|
||||||
|
labels.insert("triple-c.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone());
|
||||||
|
labels.insert("triple-c.instructions-fingerprint".to_string(),
|
||||||
|
combined_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default());
|
||||||
|
labels.insert("triple-c.git-user-name".to_string(), project.git_user_name.clone().unwrap_or_default());
|
||||||
|
labels.insert("triple-c.git-user-email".to_string(), project.git_user_email.clone().unwrap_or_default());
|
||||||
|
labels.insert("triple-c.git-token-hash".to_string(),
|
||||||
|
project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default());
|
||||||
|
|
||||||
let host_config = HostConfig {
|
let host_config = HostConfig {
|
||||||
mounts: Some(mounts),
|
mounts: Some(mounts),
|
||||||
@@ -1000,41 +1007,32 @@ pub async fn container_needs_recreation(
|
|||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Git environment variables ────────────────────────────────────────
|
// ── Git settings (label-based to avoid stale snapshot env vars) ─────
|
||||||
let env_vars = info
|
let expected_git_name = project.git_user_name.clone().unwrap_or_default();
|
||||||
.config
|
let container_git_name = get_label("triple-c.git-user-name").unwrap_or_default();
|
||||||
.as_ref()
|
if container_git_name != expected_git_name {
|
||||||
.and_then(|c| c.env.as_ref());
|
log::info!("GIT_USER_NAME mismatch (container={:?}, project={:?})", container_git_name, expected_git_name);
|
||||||
|
|
||||||
let get_env = |name: &str| -> Option<String> {
|
|
||||||
env_vars.and_then(|vars| {
|
|
||||||
vars.iter()
|
|
||||||
.find(|v| v.starts_with(&format!("{}=", name)))
|
|
||||||
.map(|v| v[name.len() + 1..].to_string())
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let container_git_name = get_env("GIT_USER_NAME");
|
|
||||||
let container_git_email = get_env("GIT_USER_EMAIL");
|
|
||||||
let container_git_token = get_env("GIT_TOKEN");
|
|
||||||
|
|
||||||
if container_git_name.as_deref() != project.git_user_name.as_deref() {
|
|
||||||
log::info!("GIT_USER_NAME mismatch (container={:?}, project={:?})", container_git_name, project.git_user_name);
|
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
if container_git_email.as_deref() != project.git_user_email.as_deref() {
|
|
||||||
log::info!("GIT_USER_EMAIL mismatch (container={:?}, project={:?})", container_git_email, project.git_user_email);
|
let expected_git_email = project.git_user_email.clone().unwrap_or_default();
|
||||||
|
let container_git_email = get_label("triple-c.git-user-email").unwrap_or_default();
|
||||||
|
if container_git_email != expected_git_email {
|
||||||
|
log::info!("GIT_USER_EMAIL mismatch (container={:?}, project={:?})", container_git_email, expected_git_email);
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
if container_git_token.as_deref() != project.git_token.as_deref() {
|
|
||||||
|
let expected_git_token_hash = project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default();
|
||||||
|
let container_git_token_hash = get_label("triple-c.git-token-hash").unwrap_or_default();
|
||||||
|
if container_git_token_hash != expected_git_token_hash {
|
||||||
log::info!("GIT_TOKEN mismatch");
|
log::info!("GIT_TOKEN mismatch");
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Custom environment variables ──────────────────────────────────────
|
// ── Custom environment variables (label-based fingerprint) ──────────
|
||||||
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
||||||
let expected_fingerprint = compute_env_fingerprint(&merged_env);
|
let expected_fingerprint = compute_env_fingerprint(&merged_env);
|
||||||
let container_fingerprint = get_env("TRIPLE_C_CUSTOM_ENV").unwrap_or_default();
|
let container_fingerprint = get_label("triple-c.custom-env-fingerprint").unwrap_or_default();
|
||||||
if container_fingerprint != expected_fingerprint {
|
if container_fingerprint != expected_fingerprint {
|
||||||
log::info!("Custom env vars mismatch (container={:?}, expected={:?})", container_fingerprint, expected_fingerprint);
|
log::info!("Custom env vars mismatch (container={:?}, expected={:?})", container_fingerprint, expected_fingerprint);
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
@@ -1048,15 +1046,16 @@ pub async fn container_needs_recreation(
|
|||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Claude instructions ───────────────────────────────────────────────
|
// ── Claude instructions (label-based fingerprint) ─────────────────────
|
||||||
let expected_instructions = build_claude_instructions(
|
let expected_instructions = build_claude_instructions(
|
||||||
global_claude_instructions,
|
global_claude_instructions,
|
||||||
project.claude_instructions.as_deref(),
|
project.claude_instructions.as_deref(),
|
||||||
&project.port_mappings,
|
&project.port_mappings,
|
||||||
project.mission_control_enabled,
|
project.mission_control_enabled,
|
||||||
);
|
);
|
||||||
let container_instructions = get_env("CLAUDE_INSTRUCTIONS");
|
let expected_instructions_fp = expected_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default();
|
||||||
if container_instructions.as_deref() != expected_instructions.as_deref() {
|
let container_instructions_fp = get_label("triple-c.instructions-fingerprint").unwrap_or_default();
|
||||||
|
if container_instructions_fp != expected_instructions_fp {
|
||||||
log::info!("CLAUDE_INSTRUCTIONS mismatch");
|
log::info!("CLAUDE_INSTRUCTIONS mismatch");
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ 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 [isAutoFollow, setIsAutoFollow] = useState(true);
|
||||||
|
const isAtBottomRef = useRef(true);
|
||||||
|
// Tracks user intent to follow output — only set to false by explicit user
|
||||||
|
// actions (mouse wheel up), not by xterm scroll events during writes.
|
||||||
|
const autoFollowRef = useRef(true);
|
||||||
|
const lastUserScrollTimeRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
@@ -131,10 +137,40 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
sendInput(sessionId, data);
|
sendInput(sessionId, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track scroll position to show "Jump to Current" button
|
// Detect user-initiated scroll-up (mouse wheel) to pause auto-follow.
|
||||||
|
// Captured during capture phase so it fires before xterm's own handler.
|
||||||
|
const handleWheel = (e: WheelEvent) => {
|
||||||
|
lastUserScrollTimeRef.current = Date.now();
|
||||||
|
if (e.deltaY < 0) {
|
||||||
|
autoFollowRef.current = false;
|
||||||
|
setIsAutoFollow(false);
|
||||||
|
isAtBottomRef.current = false;
|
||||||
|
setIsAtBottom(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
containerRef.current.addEventListener("wheel", handleWheel, { capture: true, passive: true });
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// Re-enable auto-follow only when USER scrolls to bottom (not write-triggered)
|
||||||
|
const isUserScroll = (Date.now() - lastUserScrollTimeRef.current) < 300;
|
||||||
|
if (atBottom && isUserScroll && !autoFollowRef.current) {
|
||||||
|
autoFollowRef.current = true;
|
||||||
|
setIsAutoFollow(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +223,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);
|
term.write(data, () => {
|
||||||
|
if (autoFollowRef.current) {
|
||||||
|
term.scrollToBottom();
|
||||||
|
if (!isAtBottomRef.current) {
|
||||||
|
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
|
||||||
@@ -231,6 +275,9 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
|
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
resize(sessionId, term.cols, term.rows);
|
resize(sessionId, term.cols, term.rows);
|
||||||
|
if (autoFollowRef.current) {
|
||||||
|
term.scrollToBottom();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
resizeObserver.observe(containerRef.current);
|
resizeObserver.observe(containerRef.current);
|
||||||
@@ -246,9 +293,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
scrollDisposable.dispose();
|
scrollDisposable.dispose();
|
||||||
selectionDisposable.dispose();
|
selectionDisposable.dispose();
|
||||||
setTerminalHasSelection(false);
|
setTerminalHasSelection(false);
|
||||||
|
containerRef.current?.removeEventListener("wheel", handleWheel, { capture: true });
|
||||||
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 */ }
|
||||||
@@ -280,6 +329,9 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fitRef.current?.fit();
|
fitRef.current?.fit();
|
||||||
|
if (autoFollowRef.current) {
|
||||||
|
term.scrollToBottom();
|
||||||
|
}
|
||||||
term.focus();
|
term.focus();
|
||||||
} else {
|
} else {
|
||||||
// Release WebGL context for inactive terminals
|
// Release WebGL context for inactive terminals
|
||||||
@@ -314,8 +366,30 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
}, [detectedUrl]);
|
}, [detectedUrl]);
|
||||||
|
|
||||||
const handleScrollToBottom = useCallback(() => {
|
const handleScrollToBottom = useCallback(() => {
|
||||||
termRef.current?.scrollToBottom();
|
const term = termRef.current;
|
||||||
setIsAtBottom(true);
|
if (term) {
|
||||||
|
autoFollowRef.current = true;
|
||||||
|
setIsAutoFollow(true);
|
||||||
|
fitRef.current?.fit();
|
||||||
|
term.scrollToBottom();
|
||||||
|
isAtBottomRef.current = true;
|
||||||
|
setIsAtBottom(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleAutoFollow = useCallback(() => {
|
||||||
|
const next = !autoFollowRef.current;
|
||||||
|
autoFollowRef.current = next;
|
||||||
|
setIsAutoFollow(next);
|
||||||
|
if (next) {
|
||||||
|
const term = termRef.current;
|
||||||
|
if (term) {
|
||||||
|
fitRef.current?.fit();
|
||||||
|
term.scrollToBottom();
|
||||||
|
isAtBottomRef.current = true;
|
||||||
|
setIsAtBottom(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -338,6 +412,19 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
{imagePasteMsg}
|
{imagePasteMsg}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Auto-follow toggle - top right */}
|
||||||
|
<button
|
||||||
|
onClick={handleToggleAutoFollow}
|
||||||
|
className={`absolute top-2 right-4 z-50 px-2 py-1 rounded text-[10px] font-medium border shadow-sm transition-colors cursor-pointer ${
|
||||||
|
isAutoFollow
|
||||||
|
? "bg-[#1a2332] text-[#3fb950] border-[#238636] hover:bg-[#1f2d3d]"
|
||||||
|
: "bg-[#1f2937] text-[#8b949e] border-[#30363d] hover:bg-[#2d3748]"
|
||||||
|
}`}
|
||||||
|
title={isAutoFollow ? "Auto-scrolling to latest output (click to pause)" : "Auto-scroll paused (click to resume)"}
|
||||||
|
>
|
||||||
|
{isAutoFollow ? "▼ Following" : "▽ Paused"}
|
||||||
|
</button>
|
||||||
|
{/* Jump to Current - bottom right, when scrolled up */}
|
||||||
{!isAtBottom && (
|
{!isAtBottom && (
|
||||||
<button
|
<button
|
||||||
onClick={handleScrollToBottom}
|
onClick={handleScrollToBottom}
|
||||||
|
|||||||
Reference in New Issue
Block a user