Compare commits

..

5 Commits

Author SHA1 Message Date
d7d7a83aec Rename LiteLLM backend to OpenAI Compatible
All checks were successful
Build App / compute-version (push) Successful in 8s
Build App / build-macos (push) Successful in 2m25s
Build App / build-windows (push) Successful in 4m0s
Build App / build-linux (push) Successful in 4m47s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 12s
Reflects that this backend works with any OpenAI API-compatible endpoint
(LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, etc.),
not just LiteLLM. Includes serde aliases for backward compatibility with
existing projects.json files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 06:16:05 -07:00
879322bc9a Add copy/paste keyboard shortcut docs to How-To guide
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:31:41 -07:00
ecaa42fa77 Remove unused useShallow import to fix tsc build
All checks were successful
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m27s
Build App / build-windows (push) Successful in 3m33s
Build App / build-linux (push) Successful in 4m49s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 10s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:20:08 -07:00
280358166a Show copy hint in status bar when terminal text is selected
Some checks failed
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Failing after 7s
Build App / build-windows (push) Failing after 19s
Build App / build-linux (push) Failing after 1m57s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
When the user highlights text in the terminal, a "Ctrl+Shift+C to copy"
hint appears in the status bar next to the project/terminal counts.
The hint disappears when the selection is cleared.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:14:08 -07:00
4732feb33e Add Ctrl+Shift+C keyboard shortcut for copying terminal text
All checks were successful
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 3m25s
Build App / build-linux (push) Successful in 5m33s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Ctrl+C in the terminal sends SIGINT which cancels running Claude work.
This adds a custom key handler so Ctrl+Shift+C copies selected text to
the clipboard without interrupting the container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:05:10 -07:00
11 changed files with 140 additions and 100 deletions

View File

@@ -72,7 +72,7 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect) - `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
- `exec.rs` — PTY exec sessions with bidirectional stdin/stdout streaming - `exec.rs` — PTY exec sessions with bidirectional stdin/stdout streaming
- `image.rs` — Image build/pull with progress streaming - `image.rs` — Image build/pull with progress streaming
- **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `LiteLlmConfig`, `ContainerInfo`, `AppSettings`). These define the IPC contract with the frontend. - **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `OpenAiCompatibleConfig`, `ContainerInfo`, `AppSettings`). These define the IPC contract with the frontend.
- **`storage/`** — Persistence: `projects_store.rs` (JSON file with atomic writes), `secure.rs` (OS keychain via `keyring` crate), `settings_store.rs` - **`storage/`** — Persistence: `projects_store.rs` (JSON file with atomic writes), `secure.rs` (OS keychain via `keyring` crate), `settings_store.rs`
### Container (`container/`) ### Container (`container/`)
@@ -91,7 +91,7 @@ Per-project, independently configured:
- **Anthropic (OAuth)** — `claude login` in terminal, token persists in config volume - **Anthropic (OAuth)** — `claude login` in terminal, token persists in config volume
- **AWS Bedrock** — Static keys, profile, or bearer token injected as env vars - **AWS Bedrock** — Static keys, profile, or bearer token injected as env vars
- **Ollama** — Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`) - **Ollama** — Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`)
- **LiteLLM** — Connect through a LiteLLM proxy gateway via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` to access 100+ model providers - **OpenAI Compatible** — Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`
## Styling ## Styling

View File

@@ -14,7 +14,7 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
- [MCP Servers (Beta)](#mcp-servers-beta) - [MCP Servers (Beta)](#mcp-servers-beta)
- [AWS Bedrock Configuration](#aws-bedrock-configuration) - [AWS Bedrock Configuration](#aws-bedrock-configuration)
- [Ollama Configuration](#ollama-configuration) - [Ollama Configuration](#ollama-configuration)
- [LiteLLM Configuration](#litellm-configuration) - [OpenAI Compatible Configuration](#openai-compatible-configuration)
- [Settings](#settings) - [Settings](#settings)
- [Terminal Features](#terminal-features) - [Terminal Features](#terminal-features)
- [Scheduled Tasks (Inside the Container)](#scheduled-tasks-inside-the-container) - [Scheduled Tasks (Inside the Container)](#scheduled-tasks-inside-the-container)
@@ -53,7 +53,7 @@ You need access to Claude Code through one of:
- **Anthropic account** — Sign up at https://claude.ai and use `claude login` (OAuth) inside the terminal - **Anthropic account** — Sign up at https://claude.ai and use `claude login` (OAuth) inside the terminal
- **AWS Bedrock** — An AWS account with Bedrock access and Claude models enabled - **AWS Bedrock** — An AWS account with Bedrock access and Claude models enabled
- **Ollama** — A local or remote Ollama server running an Anthropic-compatible model (best-effort support) - **Ollama** — A local or remote Ollama server running an Anthropic-compatible model (best-effort support)
- **LiteLLM** — A LiteLLM proxy gateway providing access to 100+ model providers (best-effort support) - **OpenAI Compatible** — Any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, etc.) (best-effort support)
--- ---
@@ -117,11 +117,11 @@ Claude Code launches automatically with `--dangerously-skip-permissions` inside
4. Make sure the model has been pulled in Ollama (e.g., `ollama pull qwen3.5:27b`) or used via Ollama cloud before starting. 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. 5. Start the container again.
**LiteLLM:** **OpenAI Compatible:**
1. Stop the container first (settings can only be changed while stopped). 1. Stop the container first (settings can only be changed while stopped).
2. In the project card, switch the backend to **LiteLLM**. 2. In the project card, switch the backend to **OpenAI Compatible**.
3. Expand the **Config** panel and set the base URL of your LiteLLM proxy (defaults to `http://host.docker.internal:4000`). Optionally set an API key and model ID. 3. Expand the **Config** panel and set the base URL of your OpenAI-compatible endpoint (defaults to `http://host.docker.internal:4000` as an example). Optionally set an API key and model ID.
4. Start the container again. 4. Start the container again.
--- ---
@@ -427,21 +427,21 @@ Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your Ollama server in
--- ---
## LiteLLM Configuration ## OpenAI Compatible Configuration
To use Claude Code through a [LiteLLM](https://docs.litellm.ai/) proxy gateway, switch the backend to **LiteLLM** on the project card. LiteLLM supports 100+ model providers (OpenAI, Gemini, Anthropic, and more) through a single proxy. To use Claude Code through any OpenAI API-compatible endpoint, switch the backend to **OpenAI Compatible** on the project card. This works with any server that exposes an OpenAI-compatible API, including LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, and others.
### Settings ### Settings
- **Base URL** — The URL of your LiteLLM proxy. Defaults to `http://host.docker.internal:4000` for a locally running proxy. - **Base URL** — The URL of your OpenAI-compatible endpoint. Defaults to `http://host.docker.internal:4000` as an example (adjust to match your server's address and port).
- **API Key** — Optional. The API key for your LiteLLM proxy, if authentication is required. Stored securely in your OS keychain. - **API Key** — Optional. The API key for your endpoint, if authentication is required. Stored securely in your OS keychain.
- **Model ID** — Optional. Override the model to use. - **Model ID** — Optional. Override the model to use.
### How It Works ### How It Works
Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your LiteLLM proxy. If an API key is provided, it is set as `ANTHROPIC_AUTH_TOKEN`. Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your OpenAI-compatible endpoint. If an API key is provided, it is set as `ANTHROPIC_AUTH_TOKEN`.
> **Note:** 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 when routing to non-Anthropic models through the proxy. > **Note:** OpenAI Compatible 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 when routing to non-Anthropic models through the endpoint.
--- ---
@@ -494,6 +494,10 @@ When Claude Code prints a long URL (e.g., during `claude login`), Triple-C detec
Shorter URLs in terminal output are also clickable directly. Shorter URLs in terminal output are also clickable directly.
### Copying and Pasting
Use **Ctrl+Shift+C** (or **Cmd+C** on macOS) to copy selected text from the terminal, and **Ctrl+Shift+V** (or **Cmd+V** on macOS) to paste. This follows standard terminal emulator conventions since Ctrl+C is reserved for sending SIGINT.
### Clipboard Support (OSC 52) ### Clipboard Support (OSC 52)
Programs inside the container can copy text to your host clipboard. When a container program uses `xclip`, `xsel`, or `pbcopy`, the text is transparently forwarded to your host clipboard via OSC 52 escape sequences. No additional configuration is required — this works out of the box. Programs inside the container can copy text to your host clipboard. When a container program uses `xclip`, `xsel`, or `pbcopy`, the text is transparently forwarded to your host clipboard via OSC 52 escape sequences. No additional configuration is required — this works out of the box.

View File

@@ -50,9 +50,9 @@ 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. - **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. - **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`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container. - **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. - **OpenAI Compatible**: Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, etc.) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. 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. > **Note:** Ollama and OpenAI Compatible 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.
### Container Spawning (Sibling Containers) ### Container Spawning (Sibling Containers)

View File

@@ -34,9 +34,9 @@ fn store_secrets_for_project(project: &Project) -> Result<(), String> {
secure::store_project_secret(&project.id, "aws-bearer-token", v)?; secure::store_project_secret(&project.id, "aws-bearer-token", v)?;
} }
} }
if let Some(ref litellm) = project.litellm_config { if let Some(ref oai_config) = project.openai_compatible_config {
if let Some(ref v) = litellm.api_key { if let Some(ref v) = oai_config.api_key {
secure::store_project_secret(&project.id, "litellm-api-key", v)?; secure::store_project_secret(&project.id, "openai-compatible-api-key", v)?;
} }
} }
Ok(()) Ok(())
@@ -56,8 +56,8 @@ fn load_secrets_for_project(project: &mut Project) {
bedrock.aws_bearer_token = secure::get_project_secret(&project.id, "aws-bearer-token") bedrock.aws_bearer_token = secure::get_project_secret(&project.id, "aws-bearer-token")
.unwrap_or(None); .unwrap_or(None);
} }
if let Some(ref mut litellm) = project.litellm_config { if let Some(ref mut oai_config) = project.openai_compatible_config {
litellm.api_key = secure::get_project_secret(&project.id, "litellm-api-key") oai_config.api_key = secure::get_project_secret(&project.id, "openai-compatible-api-key")
.unwrap_or(None); .unwrap_or(None);
} }
} }
@@ -197,11 +197,11 @@ pub async fn start_project_container(
} }
} }
if project.backend == Backend::LiteLlm { if project.backend == Backend::OpenAiCompatible {
let litellm = project.litellm_config.as_ref() let oai_config = project.openai_compatible_config.as_ref()
.ok_or_else(|| "LiteLLM backend selected but no LiteLLM configuration found.".to_string())?; .ok_or_else(|| "OpenAI Compatible backend selected but no configuration found.".to_string())?;
if litellm.base_url.is_empty() { if oai_config.base_url.is_empty() {
return Err("LiteLLM base URL is required.".to_string()); return Err("OpenAI Compatible base URL is required.".to_string());
} }
} }

View File

@@ -244,13 +244,13 @@ fn compute_ollama_fingerprint(project: &Project) -> String {
} }
} }
/// Compute a fingerprint for the LiteLLM configuration so we can detect changes. /// Compute a fingerprint for the OpenAI Compatible configuration so we can detect changes.
fn compute_litellm_fingerprint(project: &Project) -> String { fn compute_openai_compatible_fingerprint(project: &Project) -> String {
if let Some(ref litellm) = project.litellm_config { if let Some(ref config) = project.openai_compatible_config {
let parts = vec![ let parts = vec![
litellm.base_url.clone(), config.base_url.clone(),
litellm.api_key.as_deref().unwrap_or("").to_string(), config.api_key.as_deref().unwrap_or("").to_string(),
litellm.model_id.as_deref().unwrap_or("").to_string(), config.model_id.as_deref().unwrap_or("").to_string(),
]; ];
sha256_hex(&parts.join("|")) sha256_hex(&parts.join("|"))
} else { } else {
@@ -516,14 +516,14 @@ pub async fn create_container(
} }
} }
// LiteLLM configuration // OpenAI Compatible configuration
if project.backend == Backend::LiteLlm { if project.backend == Backend::OpenAiCompatible {
if let Some(ref litellm) = project.litellm_config { if let Some(ref config) = project.openai_compatible_config {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", litellm.base_url)); env_vars.push(format!("ANTHROPIC_BASE_URL={}", config.base_url));
if let Some(ref key) = litellm.api_key { if let Some(ref key) = config.api_key {
env_vars.push(format!("ANTHROPIC_AUTH_TOKEN={}", key)); env_vars.push(format!("ANTHROPIC_AUTH_TOKEN={}", key));
} }
if let Some(ref model) = litellm.model_id { if let Some(ref model) = config.model_id {
env_vars.push(format!("ANTHROPIC_MODEL={}", model)); env_vars.push(format!("ANTHROPIC_MODEL={}", model));
} }
} }
@@ -698,7 +698,7 @@ pub async fn create_container(
labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths)); labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths));
labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project)); labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project));
labels.insert("triple-c.ollama-fingerprint".to_string(), compute_ollama_fingerprint(project)); labels.insert("triple-c.ollama-fingerprint".to_string(), compute_ollama_fingerprint(project));
labels.insert("triple-c.litellm-fingerprint".to_string(), compute_litellm_fingerprint(project)); labels.insert("triple-c.openai-compatible-fingerprint".to_string(), compute_openai_compatible_fingerprint(project));
labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings)); labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings));
labels.insert("triple-c.image".to_string(), image_name.to_string()); labels.insert("triple-c.image".to_string(), image_name.to_string());
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string()); labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string());
@@ -948,11 +948,11 @@ pub async fn container_needs_recreation(
return Ok(true); return Ok(true);
} }
// ── LiteLLM config fingerprint ─────────────────────────────────────── // ── OpenAI Compatible config fingerprint ────────────────────────────
let expected_litellm_fp = compute_litellm_fingerprint(project); let expected_oai_fp = compute_openai_compatible_fingerprint(project);
let container_litellm_fp = get_label("triple-c.litellm-fingerprint").unwrap_or_default(); let container_oai_fp = get_label("triple-c.openai-compatible-fingerprint").unwrap_or_default();
if container_litellm_fp != expected_litellm_fp { if container_oai_fp != expected_oai_fp {
log::info!("LiteLLM config mismatch"); log::info!("OpenAI Compatible config mismatch");
return Ok(true); return Ok(true);
} }

View File

@@ -35,7 +35,8 @@ pub struct Project {
pub backend: Backend, pub backend: Backend,
pub bedrock_config: Option<BedrockConfig>, pub bedrock_config: Option<BedrockConfig>,
pub ollama_config: Option<OllamaConfig>, pub ollama_config: Option<OllamaConfig>,
pub litellm_config: Option<LiteLlmConfig>, #[serde(alias = "litellm_config")]
pub openai_compatible_config: Option<OpenAiCompatibleConfig>,
pub allow_docker_access: bool, pub allow_docker_access: bool,
#[serde(default)] #[serde(default)]
pub mission_control_enabled: bool, pub mission_control_enabled: bool,
@@ -70,7 +71,7 @@ pub enum ProjectStatus {
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container) /// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials /// - `Bedrock`: AWS Bedrock with per-project AWS credentials
/// - `Ollama`: Local or remote Ollama server /// - `Ollama`: Local or remote Ollama server
/// - `LiteLlm`: LiteLLM proxy gateway for 100+ model providers /// - `OpenAiCompatible`: Any OpenAI API-compatible endpoint (e.g., LiteLLM, vLLM, etc.)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum Backend { pub enum Backend {
@@ -79,8 +80,8 @@ pub enum Backend {
Anthropic, Anthropic,
Bedrock, Bedrock,
Ollama, Ollama,
#[serde(alias = "litellm")] #[serde(alias = "lite_llm", alias = "litellm")]
LiteLlm, OpenAiCompatible,
} }
impl Default for Backend { impl Default for Backend {
@@ -132,13 +133,14 @@ pub struct OllamaConfig {
pub model_id: Option<String>, pub model_id: Option<String>,
} }
/// LiteLLM gateway configuration for a project. /// OpenAI Compatible endpoint configuration for a project.
/// LiteLLM translates Anthropic API calls to 100+ model providers. /// Routes Anthropic API calls through any OpenAI API-compatible endpoint
/// (e.g., LiteLLM, vLLM, or other compatible gateways).
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiteLlmConfig { pub struct OpenAiCompatibleConfig {
/// The base URL of the LiteLLM proxy (e.g., "http://host.docker.internal:4000" or "https://litellm.example.com") /// The base URL of the OpenAI-compatible endpoint (e.g., "http://host.docker.internal:4000" or "https://api.example.com")
pub base_url: String, pub base_url: String,
/// API key for the LiteLLM proxy /// API key for the OpenAI-compatible endpoint
#[serde(skip_serializing, default)] #[serde(skip_serializing, default)]
pub api_key: Option<String>, pub api_key: Option<String>,
/// Optional model override /// Optional model override
@@ -157,7 +159,7 @@ impl Project {
backend: Backend::default(), backend: Backend::default(),
bedrock_config: None, bedrock_config: None,
ollama_config: None, ollama_config: None,
litellm_config: None, openai_compatible_config: None,
allow_docker_access: false, allow_docker_access: false,
mission_control_enabled: false, mission_control_enabled: false,
ssh_key_path: None, ssh_key_path: None,

View File

@@ -2,8 +2,8 @@ import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
export default function StatusBar() { export default function StatusBar() {
const { projects, sessions } = useAppState( const { projects, sessions, terminalHasSelection } = useAppState(
useShallow(s => ({ projects: s.projects, sessions: s.sessions })) useShallow(s => ({ projects: s.projects, sessions: s.sessions, terminalHasSelection: s.terminalHasSelection }))
); );
const running = projects.filter((p) => p.status === "running").length; const running = projects.filter((p) => p.status === "running").length;
@@ -20,6 +20,12 @@ export default function StatusBar() {
<span> <span>
{sessions.length} terminal{sessions.length !== 1 ? "s" : ""} {sessions.length} terminal{sessions.length !== 1 ? "s" : ""}
</span> </span>
{terminalHasSelection && (
<>
<span className="mx-2">|</span>
<span className="text-[var(--accent)]">Ctrl+Shift+C to copy</span>
</>
)}
</div> </div>
); );
} }

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import type { Project, ProjectPath, Backend, BedrockConfig, BedrockAuthMethod, OllamaConfig, LiteLlmConfig } from "../../lib/types"; import type { Project, ProjectPath, Backend, BedrockConfig, BedrockAuthMethod, OllamaConfig, OpenAiCompatibleConfig } from "../../lib/types";
import { useProjects } from "../../hooks/useProjects"; import { useProjects } from "../../hooks/useProjects";
import { useMcpServers } from "../../hooks/useMcpServers"; import { useMcpServers } from "../../hooks/useMcpServers";
import { useTerminal } from "../../hooks/useTerminal"; import { useTerminal } from "../../hooks/useTerminal";
@@ -63,10 +63,10 @@ export default function ProjectCard({ project }: Props) {
const [ollamaBaseUrl, setOllamaBaseUrl] = useState(project.ollama_config?.base_url ?? "http://host.docker.internal:11434"); const [ollamaBaseUrl, setOllamaBaseUrl] = useState(project.ollama_config?.base_url ?? "http://host.docker.internal:11434");
const [ollamaModelId, setOllamaModelId] = useState(project.ollama_config?.model_id ?? ""); const [ollamaModelId, setOllamaModelId] = useState(project.ollama_config?.model_id ?? "");
// LiteLLM local state // OpenAI Compatible local state
const [litellmBaseUrl, setLitellmBaseUrl] = useState(project.litellm_config?.base_url ?? "http://host.docker.internal:4000"); const [openaiCompatibleBaseUrl, setOpenaiCompatibleBaseUrl] = useState(project.openai_compatible_config?.base_url ?? "http://host.docker.internal:4000");
const [litellmApiKey, setLitellmApiKey] = useState(project.litellm_config?.api_key ?? ""); const [openaiCompatibleApiKey, setOpenaiCompatibleApiKey] = useState(project.openai_compatible_config?.api_key ?? "");
const [litellmModelId, setLitellmModelId] = useState(project.litellm_config?.model_id ?? ""); const [openaiCompatibleModelId, setOpenaiCompatibleModelId] = useState(project.openai_compatible_config?.model_id ?? "");
// Sync local state when project prop changes (e.g., after save or external update) // Sync local state when project prop changes (e.g., after save or external update)
useEffect(() => { useEffect(() => {
@@ -88,9 +88,9 @@ export default function ProjectCard({ project }: Props) {
setBedrockModelId(project.bedrock_config?.model_id ?? ""); setBedrockModelId(project.bedrock_config?.model_id ?? "");
setOllamaBaseUrl(project.ollama_config?.base_url ?? "http://host.docker.internal:11434"); setOllamaBaseUrl(project.ollama_config?.base_url ?? "http://host.docker.internal:11434");
setOllamaModelId(project.ollama_config?.model_id ?? ""); setOllamaModelId(project.ollama_config?.model_id ?? "");
setLitellmBaseUrl(project.litellm_config?.base_url ?? "http://host.docker.internal:4000"); setOpenaiCompatibleBaseUrl(project.openai_compatible_config?.base_url ?? "http://host.docker.internal:4000");
setLitellmApiKey(project.litellm_config?.api_key ?? ""); setOpenaiCompatibleApiKey(project.openai_compatible_config?.api_key ?? "");
setLitellmModelId(project.litellm_config?.model_id ?? ""); setOpenaiCompatibleModelId(project.openai_compatible_config?.model_id ?? "");
}, [project]); }, [project]);
// Listen for container progress events // Listen for container progress events
@@ -197,7 +197,7 @@ export default function ProjectCard({ project }: Props) {
model_id: null, model_id: null,
}; };
const defaultLiteLlmConfig: LiteLlmConfig = { const defaultOpenAiCompatibleConfig: OpenAiCompatibleConfig = {
base_url: "http://host.docker.internal:4000", base_url: "http://host.docker.internal:4000",
api_key: null, api_key: null,
model_id: null, model_id: null,
@@ -212,8 +212,8 @@ export default function ProjectCard({ project }: Props) {
if (mode === "ollama" && !project.ollama_config) { if (mode === "ollama" && !project.ollama_config) {
updates.ollama_config = defaultOllamaConfig; updates.ollama_config = defaultOllamaConfig;
} }
if (mode === "lite_llm" && !project.litellm_config) { if (mode === "open_ai_compatible" && !project.openai_compatible_config) {
updates.litellm_config = defaultLiteLlmConfig; updates.openai_compatible_config = defaultOpenAiCompatibleConfig;
} }
await update({ ...project, ...updates }); await update({ ...project, ...updates });
} catch (e) { } catch (e) {
@@ -355,30 +355,30 @@ export default function ProjectCard({ project }: Props) {
} }
}; };
const handleLitellmBaseUrlBlur = async () => { const handleOpenaiCompatibleBaseUrlBlur = async () => {
try { try {
const current = project.litellm_config ?? defaultLiteLlmConfig; const current = project.openai_compatible_config ?? defaultOpenAiCompatibleConfig;
await update({ ...project, litellm_config: { ...current, base_url: litellmBaseUrl } }); await update({ ...project, openai_compatible_config: { ...current, base_url: openaiCompatibleBaseUrl } });
} catch (err) { } catch (err) {
console.error("Failed to update LiteLLM base URL:", err); console.error("Failed to update OpenAI Compatible base URL:", err);
} }
}; };
const handleLitellmApiKeyBlur = async () => { const handleOpenaiCompatibleApiKeyBlur = async () => {
try { try {
const current = project.litellm_config ?? defaultLiteLlmConfig; const current = project.openai_compatible_config ?? defaultOpenAiCompatibleConfig;
await update({ ...project, litellm_config: { ...current, api_key: litellmApiKey || null } }); await update({ ...project, openai_compatible_config: { ...current, api_key: openaiCompatibleApiKey || null } });
} catch (err) { } catch (err) {
console.error("Failed to update LiteLLM API key:", err); console.error("Failed to update OpenAI Compatible API key:", err);
} }
}; };
const handleLitellmModelIdBlur = async () => { const handleOpenaiCompatibleModelIdBlur = async () => {
try { try {
const current = project.litellm_config ?? defaultLiteLlmConfig; const current = project.openai_compatible_config ?? defaultOpenAiCompatibleConfig;
await update({ ...project, litellm_config: { ...current, model_id: litellmModelId || null } }); await update({ ...project, openai_compatible_config: { ...current, model_id: openaiCompatibleModelId || null } });
} catch (err) { } catch (err) {
console.error("Failed to update LiteLLM model ID:", err); console.error("Failed to update OpenAI Compatible model ID:", err);
} }
}; };
@@ -449,7 +449,7 @@ export default function ProjectCard({ project }: Props) {
<div className="mt-2 ml-4 space-y-2 min-w-0 overflow-hidden"> <div className="mt-2 ml-4 space-y-2 min-w-0 overflow-hidden">
{/* Backend selector */} {/* Backend selector */}
<div className="flex items-center gap-1 text-xs"> <div className="flex items-center gap-1 text-xs">
<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> <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. OpenAI Compatible: Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.) to access 100+ model providers." /></span>
<select <select
value={project.backend} value={project.backend}
onChange={(e) => { e.stopPropagation(); handleBackendChange(e.target.value as Backend); }} onChange={(e) => { e.stopPropagation(); handleBackendChange(e.target.value as Backend); }}
@@ -460,7 +460,7 @@ export default function ProjectCard({ project }: Props) {
<option value="anthropic">Anthropic</option> <option value="anthropic">Anthropic</option>
<option value="bedrock">Bedrock</option> <option value="bedrock">Bedrock</option>
<option value="ollama">Ollama</option> <option value="ollama">Ollama</option>
<option value="lite_llm">LiteLLM</option> <option value="open_ai_compatible">OpenAI Compatible</option>
</select> </select>
</div> </div>
@@ -956,38 +956,38 @@ export default function ProjectCard({ project }: Props) {
); );
})()} })()}
{/* LiteLLM config */} {/* OpenAI Compatible config */}
{project.backend === "lite_llm" && (() => { {project.backend === "open_ai_compatible" && (() => {
const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"; const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50";
return ( return (
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]"> <div className="space-y-2 pt-1 border-t border-[var(--border-color)]">
<label className="block text-xs font-medium text-[var(--text-primary)]">LiteLLM Gateway</label> <label className="block text-xs font-medium text-[var(--text-primary)]">OpenAI Compatible Endpoint</label>
<p className="text-xs text-[var(--text-secondary)]"> <p className="text-xs text-[var(--text-secondary)]">
Connect through a LiteLLM proxy to use 100+ model providers. Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.).
</p> </p>
<div> <div>
<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> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">Base URL<Tooltip text="URL of your OpenAI API-compatible server. Use host.docker.internal for a locally running service." /></label>
<input <input
value={litellmBaseUrl} value={openaiCompatibleBaseUrl}
onChange={(e) => setLitellmBaseUrl(e.target.value)} onChange={(e) => setOpenaiCompatibleBaseUrl(e.target.value)}
onBlur={handleLitellmBaseUrlBlur} onBlur={handleOpenaiCompatibleBaseUrlBlur}
placeholder="http://host.docker.internal:4000" placeholder="http://host.docker.internal:4000"
disabled={!isStopped} disabled={!isStopped}
className={inputCls} className={inputCls}
/> />
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-70"> <p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-70">
Use host.docker.internal for local, or a URL for remote/containerized LiteLLM. Use host.docker.internal for local, or a URL for a remote OpenAI-compatible service.
</p> </p>
</div> </div>
<div> <div>
<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> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">API Key<Tooltip text="Authentication key for your OpenAI-compatible endpoint, if required." /></label>
<input <input
type="password" type="password"
value={litellmApiKey} value={openaiCompatibleApiKey}
onChange={(e) => setLitellmApiKey(e.target.value)} onChange={(e) => setOpenaiCompatibleApiKey(e.target.value)}
onBlur={handleLitellmApiKeyBlur} onBlur={handleOpenaiCompatibleApiKeyBlur}
placeholder="sk-..." placeholder="sk-..."
disabled={!isStopped} disabled={!isStopped}
className={inputCls} className={inputCls}
@@ -995,11 +995,11 @@ export default function ProjectCard({ project }: Props) {
</div> </div>
<div> <div>
<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> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model (optional)<Tooltip text="Model identifier as configured in your provider (e.g. gpt-4o, gemini-pro)." /></label>
<input <input
value={litellmModelId} value={openaiCompatibleModelId}
onChange={(e) => setLitellmModelId(e.target.value)} onChange={(e) => setOpenaiCompatibleModelId(e.target.value)}
onBlur={handleLitellmModelIdBlur} onBlur={handleOpenaiCompatibleModelIdBlur}
placeholder="gpt-4o / gemini-pro / etc." placeholder="gpt-4o / gemini-pro / etc."
disabled={!isStopped} disabled={!isStopped}
className={inputCls} className={inputCls}

View File

@@ -24,6 +24,7 @@ export default function TerminalView({ sessionId, active }: Props) {
const webglRef = useRef<WebglAddon | null>(null); const webglRef = useRef<WebglAddon | null>(null);
const detectorRef = useRef<UrlDetector | null>(null); const detectorRef = useRef<UrlDetector | null>(null);
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal(); const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
const ssoBufferRef = useRef(""); const ssoBufferRef = useRef("");
const ssoTriggeredRef = useRef(false); const ssoTriggeredRef = useRef(false);
@@ -80,6 +81,22 @@ export default function TerminalView({ sessionId, active }: Props) {
term.open(containerRef.current); 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 // WebGL addon is loaded/disposed dynamically in the active effect
// to avoid exhausting the browser's limited WebGL context pool. // to avoid exhausting the browser's limited WebGL context pool.
@@ -120,6 +137,11 @@ export default function TerminalView({ sessionId, active }: Props) {
setIsAtBottom(buf.viewportY >= buf.baseY); setIsAtBottom(buf.viewportY >= buf.baseY);
}); });
// Track text selection to show copy hint in status bar
const selectionDisposable = term.onSelectionChange(() => {
setTerminalHasSelection(term.hasSelection());
});
// Handle image paste: intercept paste events with image data, // Handle image paste: intercept paste events with image data,
// upload to the container, and inject the file path into terminal input. // upload to the container, and inject the file path into terminal input.
const handlePaste = (e: ClipboardEvent) => { const handlePaste = (e: ClipboardEvent) => {
@@ -222,6 +244,8 @@ export default function TerminalView({ sessionId, active }: Props) {
osc52Disposable.dispose(); osc52Disposable.dispose();
inputDisposable.dispose(); inputDisposable.dispose();
scrollDisposable.dispose(); scrollDisposable.dispose();
selectionDisposable.dispose();
setTerminalHasSelection(false);
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?.());

View File

@@ -23,7 +23,7 @@ export interface Project {
backend: Backend; backend: Backend;
bedrock_config: BedrockConfig | null; bedrock_config: BedrockConfig | null;
ollama_config: OllamaConfig | null; ollama_config: OllamaConfig | null;
litellm_config: LiteLlmConfig | null; openai_compatible_config: OpenAiCompatibleConfig | null;
allow_docker_access: boolean; allow_docker_access: boolean;
mission_control_enabled: boolean; mission_control_enabled: boolean;
ssh_key_path: string | null; ssh_key_path: string | null;
@@ -45,7 +45,7 @@ export type ProjectStatus =
| "stopping" | "stopping"
| "error"; | "error";
export type Backend = "anthropic" | "bedrock" | "ollama" | "lite_llm"; export type Backend = "anthropic" | "bedrock" | "ollama" | "open_ai_compatible";
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token"; export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
@@ -66,7 +66,7 @@ export interface OllamaConfig {
model_id: string | null; model_id: string | null;
} }
export interface LiteLlmConfig { export interface OpenAiCompatibleConfig {
base_url: string; base_url: string;
api_key: string | null; api_key: string | null;
model_id: string | null; model_id: string | null;

View File

@@ -24,6 +24,8 @@ interface AppState {
removeMcpServerFromList: (id: string) => void; removeMcpServerFromList: (id: string) => void;
// UI state // UI state
terminalHasSelection: boolean;
setTerminalHasSelection: (has: boolean) => void;
sidebarView: "projects" | "mcp" | "settings"; sidebarView: "projects" | "mcp" | "settings";
setSidebarView: (view: "projects" | "mcp" | "settings") => void; setSidebarView: (view: "projects" | "mcp" | "settings") => void;
dockerAvailable: boolean | null; dockerAvailable: boolean | null;
@@ -100,6 +102,8 @@ export const useAppState = create<AppState>((set) => ({
})), })),
// UI state // UI state
terminalHasSelection: false,
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
sidebarView: "projects", sidebarView: "projects",
setSidebarView: (view) => set({ sidebarView: view }), setSidebarView: (view) => set({ sidebarView: view }),
dockerAvailable: null, dockerAvailable: null,