Compare commits
23 Commits
v0.3.23-mac
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ccdfc52dce | |||
| 2e661979ea | |||
| 59d89bcd1b | |||
| 26adccce5b | |||
| 876ba8a8fc | |||
| 5cd528a4ef | |||
| c3fc029b1d | |||
| dc253e8da0 | |||
| 3e2e3f231b | |||
| 01a8f5c503 | |||
| 0945e21eb1 | |||
| d65872dc94 | |||
| edf0698774 | |||
| 84e0bdf7b4 | |||
| 10e689eaa6 | |||
| d07dcdfea9 | |||
| 424ab04ca8 | |||
| 1716fb5c82 | |||
| da7b7b9bd5 | |||
| 3d2d979197 | |||
| de2752557d | |||
| 9221c25474 | |||
| 997e1ab3a9 |
@@ -428,20 +428,62 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload to Gitea release
|
- name: Upload to Gitea release
|
||||||
if: gitea.event_name == 'push'
|
if: gitea.event_name == 'push'
|
||||||
|
shell: powershell
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
COMMIT_SHA: ${{ gitea.sha }}
|
COMMIT_SHA: ${{ gitea.sha }}
|
||||||
|
VERSION: ${{ needs.compute-version.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
set "TAG=v${{ needs.compute-version.outputs.version }}-win"
|
$ErrorActionPreference = "Stop"
|
||||||
echo Creating release %TAG%...
|
$tag = "v$env:VERSION-win"
|
||||||
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/json" -d "{\"tag_name\": \"%TAG%\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (Windows)\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json
|
$headers = @{ Authorization = "token $env:TOKEN" }
|
||||||
for /f "tokens=2 delims=:," %%a in ('findstr /c:"\"id\"" release.json') do set "RELEASE_ID=%%a" & goto :found
|
$api = "$env:GITEA_URL/api/v1/repos/$env:REPO"
|
||||||
:found
|
|
||||||
echo Release ID: %RELEASE_ID%
|
# Idempotent get-or-create. The old cmd-batch version swallowed
|
||||||
for %%f in (artifacts\*) do (
|
# curl errors and parsed the release id with findstr, so a 409 on
|
||||||
echo Uploading %%~nxf...
|
# a pre-existing tag yielded an empty RELEASE_ID and uploads went to
|
||||||
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/octet-stream" --data-binary "@%%f" "%GITEA_URL%/api/v1/repos/%REPO%/releases/%RELEASE_ID%/assets?name=%%~nxf"
|
# a malformed .../releases//assets URL while the step still reported
|
||||||
)
|
# success. Look the release up by tag first; create only on 404.
|
||||||
|
try {
|
||||||
|
$release = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/tags/$tag"
|
||||||
|
Write-Host "Release $tag already exists, reusing"
|
||||||
|
} catch {
|
||||||
|
if ($_.Exception.Response.StatusCode.value__ -eq 404) {
|
||||||
|
Write-Host "Release $tag not found, creating"
|
||||||
|
$body = @{
|
||||||
|
tag_name = $tag
|
||||||
|
name = "Triple-C v$env:VERSION (Windows)"
|
||||||
|
body = "Automated build from commit $env:COMMIT_SHA"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
$release = Invoke-RestMethod -Method Post -Headers $headers `
|
||||||
|
-ContentType "application/json" -Body $body -Uri "$api/releases"
|
||||||
|
} else {
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$releaseId = $release.id
|
||||||
|
if (-not $releaseId) { throw "Failed to resolve release id for $tag" }
|
||||||
|
Write-Host "Release ID: $releaseId"
|
||||||
|
|
||||||
|
# Upload each artifact. Delete any same-named asset left over from a
|
||||||
|
# partial prior run first, so the upload replaces rather than 409s.
|
||||||
|
$existing = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/$releaseId/assets"
|
||||||
|
foreach ($file in Get-ChildItem -File -Path artifacts\*) {
|
||||||
|
$name = $file.Name
|
||||||
|
$dupe = $existing | Where-Object { $_.name -eq $name }
|
||||||
|
if ($dupe) {
|
||||||
|
Write-Host "Deleting existing asset $name (id $($dupe.id))"
|
||||||
|
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$releaseId/assets/$($dupe.id)" | Out-Null
|
||||||
|
}
|
||||||
|
Write-Host "Uploading $name..."
|
||||||
|
$uploadUri = "$api/releases/$releaseId/assets?name=$([uri]::EscapeDataString($name))"
|
||||||
|
curl.exe -fsS --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 `
|
||||||
|
-X POST -H "Authorization: token $env:TOKEN" `
|
||||||
|
-H "Content-Type: application/octet-stream" `
|
||||||
|
--data-binary "@$($file.FullName)" $uploadUri
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Upload of $name failed (curl exit $LASTEXITCODE)" }
|
||||||
|
}
|
||||||
|
|
||||||
create-tag:
|
create-tag:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na
|
|||||||
- **Web links addon** — `@xterm/addon-web-links` makes URLs in terminal output clickable. Combined with `tauri-plugin-opener`, clicked URLs open in the host browser — essential for the `claude login` OAuth flow where Claude prints an authentication URL that must be opened on the host.
|
- **Web links addon** — `@xterm/addon-web-links` makes URLs in terminal output clickable. Combined with `tauri-plugin-opener`, clicked URLs open in the host browser — essential for the `claude login` OAuth flow where Claude prints an authentication URL that must be opened on the host.
|
||||||
- **Bidirectional data flow** — xterm.js exposes `term.onData()` for user keystrokes and `term.write()` for incoming data. This maps directly to our Tauri event-based streaming architecture.
|
- **Bidirectional data flow** — xterm.js exposes `term.onData()` for user keystrokes and `term.write()` for incoming data. This maps directly to our Tauri event-based streaming architecture.
|
||||||
|
|
||||||
|
#### Terminal Layout & StatusBar Controls
|
||||||
|
|
||||||
|
Implementation gotchas for the terminal view and its global controls (merged in PR #7, `terminal-layout-statusbar`):
|
||||||
|
|
||||||
|
- **xterm padding lives on a wrapper, never the host.** FitAddon measures the same element that `term.open()` mounts into, so any padding on that host element makes the grid overhang and clip its rightmost column / bottom row. Padding must live on a **wrapper `div`**; the xterm host fills it with no padding of its own. Do not reintroduce padding on the host element in `TerminalView.tsx`.
|
||||||
|
- **STT mic and "Jump to Current" live in the global `StatusBar`, not per-terminal overlays.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`).
|
||||||
|
- **Recording is pinned to where it started.** The STT transcript targets `recordingSessionIdRef` (the session recording began in), **not** the live active session — switching tabs mid-recording must not misroute the transcript.
|
||||||
|
- **"Jump to Current" state is written only by the active terminal.** The active `TerminalView` surfaces `terminalAtBottom` and `scrollActiveToBottom` through the store; only the active terminal writes them, and they are cleared on its unmount.
|
||||||
|
- **Set store function values via object-merge, not the updater form** — `set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `scrollActiveToBottom`) into the Zustand store.
|
||||||
|
|
||||||
### bollard (Docker API)
|
### bollard (Docker API)
|
||||||
|
|
||||||
**Chosen over:** Shelling out to the `docker` CLI, dockerode (Node.js), docker-api (Python)
|
**Chosen over:** Shelling out to the `docker` CLI, dockerode (Node.js), docker-api (Python)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use bollard::container::{DownloadFromContainerOptions, UploadToContainerOptions};
|
use bollard::container::{DownloadFromContainerOptions, LogOutput, UploadToContainerOptions};
|
||||||
|
use bollard::exec::{CreateExecOptions, StartExecResults};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
@@ -151,6 +152,198 @@ pub async fn download_container_file(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a `.tar.gz` backup of the container and stream it to a host file.
|
||||||
|
/// The archive contains:
|
||||||
|
/// - the workspace (default /workspace), minus regenerable build artifacts
|
||||||
|
/// (node_modules, target), under `workspace/`, and
|
||||||
|
/// - a sanitized copy of the home config under `home-claude/`: ~/.claude.json
|
||||||
|
/// with secret-bearing keys removed (mcpServers/settings kept) and ~/.claude/
|
||||||
|
/// minus the OAuth `.credentials.json`, so MCP servers, settings and skills
|
||||||
|
/// set up via Claude Code survive a Reset.
|
||||||
|
/// `.git` is kept in full so the backup faithfully preserves git history,
|
||||||
|
/// including unpushed commits. Build + gzip happen inside the container so a
|
||||||
|
/// large workspace isn't streamed in full. The container must be RUNNING (the
|
||||||
|
/// backup runs via `docker exec`). Returns the number of bytes written.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn download_container_backup(
|
||||||
|
project_id: String,
|
||||||
|
host_path: String,
|
||||||
|
container_path: Option<String>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<u64, String> {
|
||||||
|
let project = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
let container_id = project
|
||||||
|
.container_id
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
|
||||||
|
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
// The backup runs inside the container via `docker exec`, which requires it
|
||||||
|
// to be running. Fail with a clear message rather than a raw Docker error.
|
||||||
|
let running = docker
|
||||||
|
.inspect_container(container_id, None)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|info| info.state)
|
||||||
|
.and_then(|s| s.running)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !running {
|
||||||
|
return Err("Start the project before backing up — the backup runs inside the running container.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = container_path.unwrap_or_else(|| "/workspace".to_string());
|
||||||
|
|
||||||
|
// Stage a sanitized home config, then tar+gzip workspace + staged config to
|
||||||
|
// stdout. mktemp/jq output go nowhere near stdout, so the only thing the
|
||||||
|
// exec emits on stdout is the archive itself. --ignore-failed-read keeps a
|
||||||
|
// transient unreadable file from aborting the whole backup. If jq can't
|
||||||
|
// parse ~/.claude.json we substitute an empty object — never the raw file —
|
||||||
|
// so secrets can't leak through the sanitization fallback.
|
||||||
|
// The `--transform` nests the workspace under `workspace/` (parallel to
|
||||||
|
// `home-claude/`) so an extracted archive has both clearly labeled instead
|
||||||
|
// of scattering the workspace files into the extraction dir. Rewriting the
|
||||||
|
// leading `.` (rather than `./`) also renames tar's root member from `./` to
|
||||||
|
// `workspace`, so the archive carries a proper `workspace/` dir entry rather
|
||||||
|
// than a bare `./` that would stamp the source root's mode/mtime onto the
|
||||||
|
// extraction directory. `flags=rh` rewrites regular member names AND
|
||||||
|
// hardlink target names (so an intra-workspace hardlink pair still resolves
|
||||||
|
// on extract) while leaving symlink targets untouched (rewriting those would
|
||||||
|
// corrupt relative/absolute links).
|
||||||
|
let script = r#"set -e
|
||||||
|
STAGE=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$STAGE"' EXIT
|
||||||
|
mkdir -p "$STAGE/home-claude"
|
||||||
|
if [ -f "$HOME/.claude.json" ]; then
|
||||||
|
if ! jq 'del(.primaryApiKey, .oauthAccount, .customApiKeyResponses)' "$HOME/.claude.json" \
|
||||||
|
> "$STAGE/home-claude/.claude.json" 2>/dev/null; then
|
||||||
|
echo "warning: could not sanitize .claude.json; omitting it from backup" >&2
|
||||||
|
printf '{}' > "$STAGE/home-claude/.claude.json"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -d "$HOME/.claude" ]; then
|
||||||
|
cp -a "$HOME/.claude" "$STAGE/home-claude/.claude" 2>/dev/null || true
|
||||||
|
rm -f "$STAGE/home-claude/.claude/.credentials.json"
|
||||||
|
fi
|
||||||
|
tar czf - --ignore-failed-read \
|
||||||
|
--exclude='*/node_modules' --exclude='*/target' \
|
||||||
|
--transform='flags=rh;s,^\.,workspace,' \
|
||||||
|
-C "$TC_BACKUP_SRC" . \
|
||||||
|
-C "$STAGE" home-claude"#;
|
||||||
|
|
||||||
|
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
|
||||||
|
|
||||||
|
let exec = docker
|
||||||
|
.create_exec(
|
||||||
|
container_id,
|
||||||
|
CreateExecOptions {
|
||||||
|
attach_stdout: Some(true),
|
||||||
|
attach_stderr: Some(true),
|
||||||
|
cmd: Some(cmd),
|
||||||
|
env: Some(vec![
|
||||||
|
"HOME=/home/claude".to_string(),
|
||||||
|
format!("TC_BACKUP_SRC={}", path),
|
||||||
|
]),
|
||||||
|
user: Some("claude".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to create backup exec: {}", e))?;
|
||||||
|
|
||||||
|
let result = docker
|
||||||
|
.start_exec(&exec.id, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to start backup exec: {}", e))?;
|
||||||
|
|
||||||
|
let mut output = match result {
|
||||||
|
StartExecResults::Attached { output, .. } => output,
|
||||||
|
StartExecResults::Detached => return Err("Backup exec started detached".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
let file = tokio::fs::File::create(&host_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to create backup file: {}", e))?;
|
||||||
|
let mut writer = tokio::io::BufWriter::new(file);
|
||||||
|
let mut total: u64 = 0;
|
||||||
|
let mut stderr_text = String::new();
|
||||||
|
let mut stream_err: Option<String> = None;
|
||||||
|
|
||||||
|
while let Some(msg) = output.next().await {
|
||||||
|
match msg {
|
||||||
|
Ok(LogOutput::StdOut { message }) => {
|
||||||
|
if let Err(e) = writer.write_all(&message).await {
|
||||||
|
stream_err = Some(format!("Failed to write backup file: {}", e));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
total += message.len() as u64;
|
||||||
|
}
|
||||||
|
Ok(LogOutput::StdErr { message }) => {
|
||||||
|
stderr_text.push_str(&String::from_utf8_lossy(&message));
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
stream_err = Some(format!("Backup stream error: {}", e));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stream_err.is_none() {
|
||||||
|
if let Err(e) = writer.flush().await {
|
||||||
|
stream_err = Some(format!("Failed to finalize backup file: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(writer);
|
||||||
|
|
||||||
|
// The tar pipeline can abort mid-stream (producing a truncated archive) and
|
||||||
|
// still have sent bytes, so a non-zero exit must be treated as failure even
|
||||||
|
// when `total > 0`. Poll until the exec actually reports finished so the
|
||||||
|
// exit code is reliably populated; if it can't be determined we fall back to
|
||||||
|
// the `total == 0` check below.
|
||||||
|
let exit_code = crate::docker::exec::wait_for_exec_exit(&exec.id).await;
|
||||||
|
|
||||||
|
if stream_err.is_none() && exit_code.is_some_and(|c| c != 0) {
|
||||||
|
stream_err = Some(format!(
|
||||||
|
"Backup command failed (exit {}){}",
|
||||||
|
exit_code.unwrap_or(-1),
|
||||||
|
if stderr_text.trim().is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(": {}", stderr_text.trim())
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if stream_err.is_none() && total == 0 {
|
||||||
|
stream_err = Some(format!(
|
||||||
|
"Backup produced no data{}",
|
||||||
|
if stderr_text.trim().is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(": {}", stderr_text.trim())
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(err) = stream_err {
|
||||||
|
// Don't leave a partial/corrupt archive behind.
|
||||||
|
let _ = tokio::fs::remove_file(&host_path).await;
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Wrote {} byte backup for project {} to {}",
|
||||||
|
total,
|
||||||
|
project_id,
|
||||||
|
host_path
|
||||||
|
);
|
||||||
|
Ok(total)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn upload_file_to_container(
|
pub async fn upload_file_to_container(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
|
|||||||
@@ -432,6 +432,13 @@ pub async fn start_project_container(
|
|||||||
new_id
|
new_id
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sync Bedrock credentials on every start: refresh static/session creds
|
||||||
|
// so rotated keys are picked up without a full container recreation, and
|
||||||
|
// clear stale creds when the project no longer uses static-cred Bedrock.
|
||||||
|
if let Err(e) = docker::sync_bedrock_credentials(&container_id, &project).await {
|
||||||
|
log::warn!("Failed to sync AWS credentials for project {}: {}", project.id, e);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(container_id)
|
Ok(container_id)
|
||||||
}.await;
|
}.await;
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,55 @@ pub async fn paste_image_to_terminal(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Copy a host file (e.g. dragged onto the terminal) into the container so
|
||||||
|
/// Claude Code can read it, and return the in-container path. Mirrors the
|
||||||
|
/// image-paste flow: the file is placed under /tmp/triple-c-drops/ keeping its
|
||||||
|
/// original name. Returns an error for paths that aren't readable regular files
|
||||||
|
/// (e.g. a dropped directory).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn upload_host_file_to_terminal(
|
||||||
|
session_id: String,
|
||||||
|
host_path: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||||
|
|
||||||
|
let meta = tokio::fs::metadata(&host_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
|
||||||
|
if meta.is_dir() {
|
||||||
|
return Err(format!("{} is a directory — drop individual files", host_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against ballooning host RAM: the file is packed into an in-memory
|
||||||
|
// tar before upload, so cap the size of a dropped file.
|
||||||
|
const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024; // 256 MiB
|
||||||
|
if meta.len() > MAX_DROP_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project or use the Files panel instead.",
|
||||||
|
meta.len() as f64 / (1024.0 * 1024.0),
|
||||||
|
MAX_DROP_BYTES / (1024 * 1024)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let base = std::path::Path::new(&host_path)
|
||||||
|
.file_name()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| "dropped-file".to_string());
|
||||||
|
|
||||||
|
// Ensure the destination directory exists rather than relying on Docker's
|
||||||
|
// archive extractor to create the parent for the uploaded tar entry.
|
||||||
|
crate::docker::exec::exec_oneshot(
|
||||||
|
&container_id,
|
||||||
|
vec!["mkdir".to_string(), "-p".to_string(), "/tmp/triple-c-drops".to_string()],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let file_name = format!("triple-c-drops/{}", base);
|
||||||
|
crate::docker::exec::upload_host_file_to_container(&container_id, &host_path, &file_name).await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn start_audio_bridge(
|
pub async fn start_audio_bridge(
|
||||||
session_id: String,
|
session_id: String,
|
||||||
|
|||||||
@@ -275,12 +275,15 @@ fn compute_bedrock_fingerprint(project: &Project, global_aws: &GlobalAwsSettings
|
|||||||
bedrock.model_id.as_deref(),
|
bedrock.model_id.as_deref(),
|
||||||
global_aws.default_model_id.as_deref(),
|
global_aws.default_model_id.as_deref(),
|
||||||
).unwrap_or("").to_string();
|
).unwrap_or("").to_string();
|
||||||
|
// NOTE: the static credential fields (access key / secret / session
|
||||||
|
// token) are intentionally NOT part of the fingerprint. They are
|
||||||
|
// written to ~/.aws/credentials on every start by
|
||||||
|
// sync_bedrock_credentials(), so a key rotation should refresh
|
||||||
|
// in place rather than force a full container recreation. Region,
|
||||||
|
// profile, and bearer token remain env-based and so stay here.
|
||||||
let parts = vec![
|
let parts = vec![
|
||||||
format!("{:?}", bedrock.auth_method),
|
format!("{:?}", bedrock.auth_method),
|
||||||
bedrock.aws_region.clone(),
|
bedrock.aws_region.clone(),
|
||||||
bedrock.aws_access_key_id.as_deref().unwrap_or("").to_string(),
|
|
||||||
bedrock.aws_secret_access_key.as_deref().unwrap_or("").to_string(),
|
|
||||||
bedrock.aws_session_token.as_deref().unwrap_or("").to_string(),
|
|
||||||
bedrock.aws_profile.as_deref().unwrap_or("").to_string(),
|
bedrock.aws_profile.as_deref().unwrap_or("").to_string(),
|
||||||
bedrock.aws_bearer_token.as_deref().unwrap_or("").to_string(),
|
bedrock.aws_bearer_token.as_deref().unwrap_or("").to_string(),
|
||||||
effective_model,
|
effective_model,
|
||||||
@@ -669,15 +672,14 @@ pub async fn create_container(
|
|||||||
|
|
||||||
match bedrock.auth_method {
|
match bedrock.auth_method {
|
||||||
BedrockAuthMethod::StaticCredentials => {
|
BedrockAuthMethod::StaticCredentials => {
|
||||||
if let Some(ref key_id) = bedrock.aws_access_key_id {
|
// Static/session credentials are NOT injected as env vars.
|
||||||
env_vars.push(format!("AWS_ACCESS_KEY_ID={}", key_id));
|
// They are written to ~/.aws/credentials by
|
||||||
}
|
// sync_bedrock_credentials() on every container
|
||||||
if let Some(ref secret) = bedrock.aws_secret_access_key {
|
// start, so rotated/updated keys are picked up without a
|
||||||
env_vars.push(format!("AWS_SECRET_ACCESS_KEY={}", secret));
|
// full container recreation (and never get baked into the
|
||||||
}
|
// snapshot image). The empty values set by the
|
||||||
if let Some(ref token) = bedrock.aws_session_token {
|
// MANAGED_AUTH_KEYS neutralization pass below are ignored by
|
||||||
env_vars.push(format!("AWS_SESSION_TOKEN={}", token));
|
// the AWS SDK, which falls through to the credentials file.
|
||||||
}
|
|
||||||
}
|
}
|
||||||
BedrockAuthMethod::Profile => {
|
BedrockAuthMethod::Profile => {
|
||||||
// Per-project profile overrides global
|
// Per-project profile overrides global
|
||||||
@@ -755,6 +757,41 @@ pub async fn create_container(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Neutralize stale backend auth env vars ──────────────────────────────
|
||||||
|
// When a project switches backends (e.g. Bedrock → Anthropic) the container
|
||||||
|
// is recreated *from a snapshot image* committed off the previous container.
|
||||||
|
// `docker commit` always bakes the previous container's full ENV into that
|
||||||
|
// image, and the commit API cannot strip it. So any auth var set under the
|
||||||
|
// old backend (e.g. CLAUDE_CODE_USE_BEDROCK=1, AWS_*) survives in the image
|
||||||
|
// ENV and stays active unless we explicitly override it at create time.
|
||||||
|
// Create-time env takes precedence over image ENV, so we set every managed
|
||||||
|
// auth key the *current* backend did NOT set to an empty value, clearing the
|
||||||
|
// stale baked-in one.
|
||||||
|
const MANAGED_AUTH_KEYS: &[&str] = &[
|
||||||
|
"CLAUDE_CODE_USE_BEDROCK",
|
||||||
|
"AWS_REGION",
|
||||||
|
"AWS_ACCESS_KEY_ID",
|
||||||
|
"AWS_SECRET_ACCESS_KEY",
|
||||||
|
"AWS_SESSION_TOKEN",
|
||||||
|
"AWS_PROFILE",
|
||||||
|
"AWS_BEARER_TOKEN_BEDROCK",
|
||||||
|
"AWS_SSO_AUTH_REFRESH_CMD",
|
||||||
|
"ANTHROPIC_BASE_URL",
|
||||||
|
"ANTHROPIC_AUTH_TOKEN",
|
||||||
|
"ANTHROPIC_MODEL",
|
||||||
|
"DISABLE_PROMPT_CACHING",
|
||||||
|
"ANTHROPIC_BEDROCK_SERVICE_TIER",
|
||||||
|
];
|
||||||
|
let already_set: std::collections::HashSet<String> = env_vars
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| e.split('=').next().map(|k| k.to_string()))
|
||||||
|
.collect();
|
||||||
|
for key in MANAGED_AUTH_KEYS {
|
||||||
|
if !already_set.contains(*key) {
|
||||||
|
env_vars.push(format!("{}=", key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Custom environment variables (global + per-project, project overrides global for same key)
|
// Custom environment variables (global + per-project, project overrides global for same key)
|
||||||
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 reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
|
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
|
||||||
@@ -897,7 +934,19 @@ pub async fn create_container(
|
|||||||
false
|
false
|
||||||
};
|
};
|
||||||
|
|
||||||
if should_mount_aws || aws_config_path.is_some() {
|
// For static-credential Bedrock, sync_bedrock_credentials() is the sole
|
||||||
|
// owner of ~/.aws/credentials (it rewrites it on every start). Mounting the
|
||||||
|
// host AWS dir would make the entrypoint's `rm -rf ~/.aws; cp -a` race that
|
||||||
|
// write at startup, so we never mount it in that case — the static keys
|
||||||
|
// (+ AWS_REGION env) are self-sufficient and don't need the host config.
|
||||||
|
let is_bedrock_static = project.backend == Backend::Bedrock
|
||||||
|
&& project
|
||||||
|
.bedrock_config
|
||||||
|
.as_ref()
|
||||||
|
.map(|b| b.auth_method == BedrockAuthMethod::StaticCredentials)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if (should_mount_aws || aws_config_path.is_some()) && !is_bedrock_static {
|
||||||
let aws_dir = aws_config_path
|
let aws_dir = aws_config_path
|
||||||
.map(|p| std::path::PathBuf::from(p))
|
.map(|p| std::path::PathBuf::from(p))
|
||||||
.or_else(|| dirs::home_dir().map(|h| h.join(".aws")));
|
.or_else(|| dirs::home_dir().map(|h| h.join(".aws")));
|
||||||
@@ -1060,10 +1109,124 @@ pub fn get_snapshot_image_name(project: &Project) -> String {
|
|||||||
format!("triple-c-snapshot-{}:latest", project.id)
|
format!("triple-c-snapshot-{}:latest", project.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keep the container's `~/.aws/credentials` in sync with the project's Bedrock
|
||||||
|
/// auth on every container start:
|
||||||
|
/// - **Bedrock + static credentials**: (re)write `~/.aws/credentials` from the
|
||||||
|
/// latest keychain values and drop a stale `~/.aws/config` left by a prior
|
||||||
|
/// profile/SSO session, so rotated keys are picked up without recreating the
|
||||||
|
/// container.
|
||||||
|
/// - **Any other backend / auth method**: remove a stale `~/.aws/credentials`
|
||||||
|
/// written by a previous static-credential session, so the secrets don't
|
||||||
|
/// linger unused in the persistent home volume after switching away.
|
||||||
|
///
|
||||||
|
/// Both cleanups are skipped when `/tmp/.host-aws` is mounted (a global
|
||||||
|
/// `aws_config_path` is configured), since the entrypoint already refreshes
|
||||||
|
/// `~/.aws` from the host on every start in that case.
|
||||||
|
pub async fn sync_bedrock_credentials(
|
||||||
|
container_id: &str,
|
||||||
|
project: &Project,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let static_bedrock = if project.backend == Backend::Bedrock {
|
||||||
|
project
|
||||||
|
.bedrock_config
|
||||||
|
.as_ref()
|
||||||
|
.filter(|b| b.auth_method == BedrockAuthMethod::StaticCredentials)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let bedrock = match static_bedrock {
|
||||||
|
Some(b) if b.aws_access_key_id.as_deref().is_some_and(|k| !k.is_empty()) => b,
|
||||||
|
_ => {
|
||||||
|
// Not static-credential Bedrock (or static selected but no key set):
|
||||||
|
// remove a stale credentials file from a previous static session.
|
||||||
|
if matches!(static_bedrock, Some(_)) {
|
||||||
|
log::warn!("Bedrock static auth selected but no AWS access key id is set");
|
||||||
|
}
|
||||||
|
let script = r#"if [ ! -d /tmp/.host-aws ]; then rm -f "$HOME/.aws/credentials"; fi"#;
|
||||||
|
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
|
||||||
|
let env = vec!["HOME=/home/claude".to_string()];
|
||||||
|
if let Err(e) = crate::docker::exec::exec_oneshot_env(container_id, cmd, env).await {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to clear stale AWS credentials in container {}: {}",
|
||||||
|
container_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let key_id = bedrock.aws_access_key_id.as_deref().unwrap_or("");
|
||||||
|
let secret = bedrock.aws_secret_access_key.as_deref().unwrap_or("");
|
||||||
|
|
||||||
|
// Pass secrets via the exec environment, then have the shell write them to
|
||||||
|
// the file. This keeps them out of the process argv (visible via `ps`).
|
||||||
|
let mut env = vec![
|
||||||
|
"HOME=/home/claude".to_string(),
|
||||||
|
format!("TC_AWS_KEY_ID={}", key_id),
|
||||||
|
format!("TC_AWS_SECRET={}", secret),
|
||||||
|
];
|
||||||
|
if let Some(token) = bedrock.aws_session_token.as_deref() {
|
||||||
|
if !token.is_empty() {
|
||||||
|
env.push(format!("TC_AWS_TOKEN={}", token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// umask 077 + explicit chmod guarantees 0600. The session-token line is only
|
||||||
|
// emitted when the variable is non-empty.
|
||||||
|
//
|
||||||
|
// We also remove a stale ~/.aws/config left over from a previous
|
||||||
|
// profile/SSO session on this project (the home volume persists across
|
||||||
|
// backend switches), so its sso_session/profile settings don't shadow the
|
||||||
|
// static [default] credentials. This is skipped when /tmp/.host-aws is
|
||||||
|
// mounted (a global aws_config_path is configured) — in that case the
|
||||||
|
// entrypoint already refreshes ~/.aws from the host on every start and the
|
||||||
|
// config is intentional.
|
||||||
|
let script = r#"set -e
|
||||||
|
umask 077
|
||||||
|
mkdir -p "$HOME/.aws"
|
||||||
|
if [ ! -d /tmp/.host-aws ] && [ -f "$HOME/.aws/config" ]; then
|
||||||
|
rm -f "$HOME/.aws/config"
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
printf '[default]\n'
|
||||||
|
printf 'aws_access_key_id=%s\n' "$TC_AWS_KEY_ID"
|
||||||
|
printf 'aws_secret_access_key=%s\n' "$TC_AWS_SECRET"
|
||||||
|
if [ -n "${TC_AWS_TOKEN:-}" ]; then
|
||||||
|
printf 'aws_session_token=%s\n' "$TC_AWS_TOKEN"
|
||||||
|
fi
|
||||||
|
} > "$HOME/.aws/credentials"
|
||||||
|
chmod 600 "$HOME/.aws/credentials""#;
|
||||||
|
|
||||||
|
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
|
||||||
|
let (output, exit_code) =
|
||||||
|
crate::docker::exec::exec_oneshot_env_status(container_id, cmd, env)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to write AWS credentials into container: {}", e))?;
|
||||||
|
if exit_code != 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"Writing AWS credentials into container failed (exit {}): {}",
|
||||||
|
exit_code,
|
||||||
|
output.trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("Wrote Bedrock static credentials into container {}", container_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Commit the container's filesystem to a snapshot image so that system-level
|
/// Commit the container's filesystem to a snapshot image so that system-level
|
||||||
/// changes (apt/pip/npm installs, ~/.claude.json, etc.) survive container
|
/// changes (apt/pip/npm installs, ~/.claude.json, etc.) survive container
|
||||||
/// removal. The Config is left empty so that secrets injected as env vars are
|
/// removal.
|
||||||
/// NOT baked into the image.
|
///
|
||||||
|
/// NOTE: `docker commit` always bakes the *running container's* full ENV into
|
||||||
|
/// the resulting image — passing an empty Config here does NOT strip it, and
|
||||||
|
/// the commit API gives no way to remove env vars. As a result auth vars (e.g.
|
||||||
|
/// CLAUDE_CODE_USE_BEDROCK, AWS_*) are present in this snapshot image's ENV.
|
||||||
|
/// `create_container` defends against that by explicitly overriding every
|
||||||
|
/// managed auth key for the active backend (see MANAGED_AUTH_KEYS), so a
|
||||||
|
/// backend switch does not inherit the previous backend's stale credentials.
|
||||||
pub async fn commit_container_snapshot(container_id: &str, project: &Project) -> Result<(), String> {
|
pub async fn commit_container_snapshot(container_id: &str, project: &Project) -> Result<(), String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let image_name = get_snapshot_image_name(project);
|
let image_name = get_snapshot_image_name(project);
|
||||||
|
|||||||
@@ -279,8 +279,90 @@ impl ExecSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upload a host file into the container's `/tmp` under `dest_name`. The file is
|
||||||
|
/// read and packed into the tar inside a blocking task, so the synchronous IO
|
||||||
|
/// runs off the async worker. The tar's declared entry size is taken from the
|
||||||
|
/// bytes actually read (not a separate `stat`), so a file changing size between
|
||||||
|
/// a size check and the read can't desync the header and corrupt the archive.
|
||||||
|
/// Returns the in-container path (`/tmp/<dest_name>`).
|
||||||
|
pub async fn upload_host_file_to_container(
|
||||||
|
container_id: &str,
|
||||||
|
host_path: &str,
|
||||||
|
dest_name: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let host_path = host_path.to_string();
|
||||||
|
let dest_name = dest_name.to_string();
|
||||||
|
let dest_for_blk = dest_name.clone();
|
||||||
|
|
||||||
|
let tar_buf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
||||||
|
let data = std::fs::read(&host_path)
|
||||||
|
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||||
|
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
|
||||||
|
{
|
||||||
|
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
// Size comes from the bytes in hand, so header and payload can't disagree.
|
||||||
|
header.set_size(data.len() as u64);
|
||||||
|
header.set_mode(0o644);
|
||||||
|
header.set_cksum();
|
||||||
|
builder
|
||||||
|
.append_data(&mut header, &dest_for_blk, &data[..])
|
||||||
|
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||||
|
builder
|
||||||
|
.finish()
|
||||||
|
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||||
|
}
|
||||||
|
Ok(tar_buf)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Upload task panicked: {}", e))??;
|
||||||
|
|
||||||
|
let docker = get_docker()?;
|
||||||
|
docker
|
||||||
|
.upload_to_container(
|
||||||
|
container_id,
|
||||||
|
Some(UploadToContainerOptions {
|
||||||
|
path: "/tmp".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
tar_buf.into(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||||
|
|
||||||
|
Ok(format!("/tmp/{}", dest_name))
|
||||||
|
}
|
||||||
|
|
||||||
/// Run a one-shot (non-interactive) exec command in a container and collect stdout.
|
/// Run a one-shot (non-interactive) exec command in a container and collect stdout.
|
||||||
pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String, String> {
|
pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String, String> {
|
||||||
|
exec_oneshot_env(container_id, cmd, Vec::new()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `exec_oneshot`, but passes additional environment variables to the exec
|
||||||
|
/// process. Secrets passed this way live only in `/proc/<pid>/environ` (readable
|
||||||
|
/// by the same user / root) rather than in the process argv, so they are not
|
||||||
|
/// exposed via `ps`.
|
||||||
|
///
|
||||||
|
/// NOTE: the command's exit code is NOT checked — callers that need to know
|
||||||
|
/// whether the command succeeded should use `exec_oneshot_env_status`.
|
||||||
|
pub async fn exec_oneshot_env(
|
||||||
|
container_id: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
env: Vec<String>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
exec_oneshot_env_status(container_id, cmd, env)
|
||||||
|
.await
|
||||||
|
.map(|(output, _exit_code)| output)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `exec_oneshot_env`, but also returns the command's exit code (0 on
|
||||||
|
/// success). The returned string contains both stdout and stderr, interleaved
|
||||||
|
/// in arrival order, which is useful for surfacing failure detail.
|
||||||
|
pub async fn exec_oneshot_env_status(
|
||||||
|
container_id: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
env: Vec<String>,
|
||||||
|
) -> Result<(String, i64), String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
|
||||||
let exec = docker
|
let exec = docker
|
||||||
@@ -290,6 +372,7 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String
|
|||||||
attach_stdout: Some(true),
|
attach_stdout: Some(true),
|
||||||
attach_stderr: Some(true),
|
attach_stderr: Some(true),
|
||||||
cmd: Some(cmd),
|
cmd: Some(cmd),
|
||||||
|
env: if env.is_empty() { None } else { Some(env) },
|
||||||
user: Some("claude".to_string()),
|
user: Some("claude".to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@@ -302,17 +385,43 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
||||||
|
|
||||||
|
let mut combined = String::new();
|
||||||
match result {
|
match result {
|
||||||
StartExecResults::Attached { mut output, .. } => {
|
StartExecResults::Attached { mut output, .. } => {
|
||||||
let mut stdout = String::new();
|
|
||||||
while let Some(msg) = output.next().await {
|
while let Some(msg) = output.next().await {
|
||||||
match msg {
|
match msg {
|
||||||
Ok(data) => stdout.push_str(&String::from_utf8_lossy(&data.into_bytes())),
|
Ok(data) => combined.push_str(&String::from_utf8_lossy(&data.into_bytes())),
|
||||||
Err(e) => return Err(format!("Exec output error: {}", e)),
|
Err(e) => return Err(format!("Exec output error: {}", e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(stdout)
|
|
||||||
}
|
}
|
||||||
StartExecResults::Detached => Err("Exec started in detached mode".to_string()),
|
StartExecResults::Detached => return Err("Exec started in detached mode".to_string()),
|
||||||
|
}
|
||||||
|
|
||||||
|
// The output stream draining doesn't strictly guarantee inspect_exec has the
|
||||||
|
// final exit_code populated yet, so poll until the exec reports finished.
|
||||||
|
let exit_code = wait_for_exec_exit(&exec.id).await.unwrap_or(0);
|
||||||
|
|
||||||
|
Ok((combined, exit_code))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll `inspect_exec` until the exec reports finished and return its exit code.
|
||||||
|
/// Returns `None` if the code can't be determined (inspect error, or the exec
|
||||||
|
/// doesn't report finished within ~1s — which shouldn't happen once its output
|
||||||
|
/// stream has drained).
|
||||||
|
pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
|
||||||
|
let docker = get_docker().ok()?;
|
||||||
|
for _ in 0..40 {
|
||||||
|
match docker.inspect_exec(exec_id).await {
|
||||||
|
Ok(info) => {
|
||||||
|
if info.running != Some(true) {
|
||||||
|
// Finished: use the reported code (default 0 if somehow absent).
|
||||||
|
return Some(info.exit_code.unwrap_or(0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Err(_) => return None,
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|||||||
@@ -178,12 +178,14 @@ pub fn run() {
|
|||||||
commands::terminal_commands::terminal_resize,
|
commands::terminal_commands::terminal_resize,
|
||||||
commands::terminal_commands::close_terminal_session,
|
commands::terminal_commands::close_terminal_session,
|
||||||
commands::terminal_commands::paste_image_to_terminal,
|
commands::terminal_commands::paste_image_to_terminal,
|
||||||
|
commands::terminal_commands::upload_host_file_to_terminal,
|
||||||
commands::terminal_commands::start_audio_bridge,
|
commands::terminal_commands::start_audio_bridge,
|
||||||
commands::terminal_commands::send_audio_data,
|
commands::terminal_commands::send_audio_data,
|
||||||
commands::terminal_commands::stop_audio_bridge,
|
commands::terminal_commands::stop_audio_bridge,
|
||||||
// Files
|
// Files
|
||||||
commands::file_commands::list_container_files,
|
commands::file_commands::list_container_files,
|
||||||
commands::file_commands::download_container_file,
|
commands::file_commands::download_container_file,
|
||||||
|
commands::file_commands::download_container_backup,
|
||||||
commands::file_commands::upload_file_to_container,
|
commands::file_commands::upload_file_to_container,
|
||||||
// MCP
|
// MCP
|
||||||
commands::mcp_commands::list_mcp_servers,
|
commands::mcp_commands::list_mcp_servers,
|
||||||
|
|||||||
+14
-3
@@ -10,6 +10,8 @@ import { useSettings } from "./hooks/useSettings";
|
|||||||
import { useProjects } from "./hooks/useProjects";
|
import { useProjects } from "./hooks/useProjects";
|
||||||
import { useMcpServers } from "./hooks/useMcpServers";
|
import { useMcpServers } from "./hooks/useMcpServers";
|
||||||
import { useUpdates } from "./hooks/useUpdates";
|
import { useUpdates } from "./hooks/useUpdates";
|
||||||
|
import { useTerminal } from "./hooks/useTerminal";
|
||||||
|
import { useSTT } from "./hooks/useSTT";
|
||||||
import { useAppState } from "./store/appState";
|
import { useAppState } from "./store/appState";
|
||||||
import { reconcileProjectStatuses } from "./lib/tauri-commands";
|
import { reconcileProjectStatuses } from "./lib/tauri-commands";
|
||||||
|
|
||||||
@@ -19,11 +21,20 @@ export default function App() {
|
|||||||
const { refresh } = useProjects();
|
const { refresh } = useProjects();
|
||||||
const { refresh: refreshMcp } = useMcpServers();
|
const { refresh: refreshMcp } = useMcpServers();
|
||||||
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
|
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
|
||||||
const { sessions, activeSessionId, setProjects } = useAppState(
|
const { sessions, activeSessionId, setProjects, setSttToggle } = useAppState(
|
||||||
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects }))
|
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects, setSttToggle: s.setSttToggle }))
|
||||||
);
|
);
|
||||||
const [showInstallDialog, setShowInstallDialog] = useState(false);
|
const [showInstallDialog, setShowInstallDialog] = useState(false);
|
||||||
|
|
||||||
|
// Single STT instance bound to the active session. The mic lives in the
|
||||||
|
// StatusBar; the terminal's Ctrl+Shift+M shortcut calls stt.toggle via the
|
||||||
|
// store (registered below).
|
||||||
|
const { sendInput } = useTerminal();
|
||||||
|
const stt = useSTT(activeSessionId ?? "", sendInput);
|
||||||
|
useEffect(() => {
|
||||||
|
setSttToggle(stt.toggle);
|
||||||
|
}, [stt.toggle, setSttToggle]);
|
||||||
|
|
||||||
// Initialize on mount
|
// Initialize on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
@@ -82,7 +93,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<StatusBar />
|
<StatusBar stt={stt} />
|
||||||
{showInstallDialog && (
|
{showInstallDialog && (
|
||||||
<DockerInstallDialog onClose={() => setShowInstallDialog(false)} />
|
<DockerInstallDialog onClose={() => setShowInstallDialog(false)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
|
import SttButton from "../terminal/SttButton";
|
||||||
|
import type { useSTT } from "../../hooks/useSTT";
|
||||||
|
|
||||||
export default function StatusBar() {
|
interface Props {
|
||||||
const { projects, sessions, terminalHasSelection } = useAppState(
|
stt: ReturnType<typeof useSTT>;
|
||||||
useShallow(s => ({ projects: s.projects, sessions: s.sessions, terminalHasSelection: s.terminalHasSelection }))
|
}
|
||||||
|
|
||||||
|
export default function StatusBar({ stt }: Props) {
|
||||||
|
const {
|
||||||
|
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
|
||||||
|
terminalAtBottom, scrollActiveToBottom,
|
||||||
|
} = useAppState(
|
||||||
|
useShallow(s => ({
|
||||||
|
projects: s.projects,
|
||||||
|
sessions: s.sessions,
|
||||||
|
terminalHasSelection: s.terminalHasSelection,
|
||||||
|
activeSessionId: s.activeSessionId,
|
||||||
|
sttEnabled: s.appSettings?.stt?.enabled,
|
||||||
|
terminalAtBottom: s.terminalAtBottom,
|
||||||
|
scrollActiveToBottom: s.scrollActiveToBottom,
|
||||||
|
}))
|
||||||
);
|
);
|
||||||
const running = projects.filter((p) => p.status === "running").length;
|
const running = projects.filter((p) => p.status === "running").length;
|
||||||
|
|
||||||
@@ -28,6 +45,26 @@ export default function StatusBar() {
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{/* Right-aligned controls: Jump to Current + STT mic */}
|
||||||
|
<div className="ml-auto flex items-center gap-3 pl-2">
|
||||||
|
{activeSessionId && !terminalAtBottom && (
|
||||||
|
<button
|
||||||
|
onClick={() => scrollActiveToBottom()}
|
||||||
|
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
|
||||||
|
title="Scroll the terminal to the latest output"
|
||||||
|
>
|
||||||
|
Jump to Current ↓
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{sttEnabled && activeSessionId && (
|
||||||
|
<SttButton
|
||||||
|
state={stt.state}
|
||||||
|
error={stt.error}
|
||||||
|
onToggle={stt.toggle}
|
||||||
|
onCancel={stt.cancelRecording}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||||
|
import * as commands from "../../lib/tauri-commands";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import type { Project, ProjectPath, Backend, BedrockConfig, BedrockAuthMethod, OllamaConfig, OpenAiCompatibleConfig } 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";
|
||||||
@@ -37,6 +38,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
|
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
|
||||||
const [operationCompleted, setOperationCompleted] = useState(false);
|
const [operationCompleted, setOperationCompleted] = useState(false);
|
||||||
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
||||||
|
const [backingUp, setBackingUp] = useState(false);
|
||||||
const [isEditingName, setIsEditingName] = useState(false);
|
const [isEditingName, setIsEditingName] = useState(false);
|
||||||
const [editName, setEditName] = useState(project.name);
|
const [editName, setEditName] = useState(project.name);
|
||||||
const isSelected = selectedProjectId === project.id;
|
const isSelected = selectedProjectId === project.id;
|
||||||
@@ -177,6 +179,35 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBackup = async () => {
|
||||||
|
if (!project.container_id) {
|
||||||
|
setError("Start the project at least once before backing up.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
||||||
|
const safeName = project.name.replace(/[^a-zA-Z0-9_-]+/g, "_");
|
||||||
|
try {
|
||||||
|
const hostPath = await save({
|
||||||
|
defaultPath: `${safeName}-backup-${stamp}.tar.gz`,
|
||||||
|
filters: [{ name: "Gzipped tarball", extensions: ["tar.gz"] }],
|
||||||
|
});
|
||||||
|
if (!hostPath) return;
|
||||||
|
setBackingUp(true);
|
||||||
|
setError(null);
|
||||||
|
const bytes = await commands.downloadContainerBackup(project.id, hostPath);
|
||||||
|
const mb = (bytes / (1024 * 1024)).toFixed(1);
|
||||||
|
const msg = `Backup saved (${mb} MB). Note: includes MCP/config — may contain MCP API keys. Keep it private.`;
|
||||||
|
setProgressMsg(msg);
|
||||||
|
// Auto-clear so the transient confirmation doesn't linger in the card
|
||||||
|
// status; guard against clobbering a newer message (e.g. a later op).
|
||||||
|
setTimeout(() => setProgressMsg((prev) => (prev === msg ? null : prev)), 8000);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBackingUp(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
setActiveOperation(null);
|
setActiveOperation(null);
|
||||||
setOperationCompleted(false);
|
setOperationCompleted(false);
|
||||||
@@ -504,6 +535,12 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
|
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
|
||||||
<ActionButton onClick={handleOpenBashShell} disabled={loading} label="Shell" />
|
<ActionButton onClick={handleOpenBashShell} disabled={loading} label="Shell" />
|
||||||
<ActionButton onClick={() => setShowFileManager(true)} disabled={loading} label="Files" />
|
<ActionButton onClick={() => setShowFileManager(true)} disabled={loading} label="Files" />
|
||||||
|
<ActionButton
|
||||||
|
onClick={handleBackup}
|
||||||
|
disabled={loading || backingUp}
|
||||||
|
label={backingUp ? "Backing up…" : "Backup"}
|
||||||
|
title="Downloads /workspace plus a sanitized home config (MCP servers, settings, skills). OAuth tokens are excluded, but MCP server configs may embed their own API keys/tokens — keep the archive private."
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1193,12 +1230,14 @@ function ActionButton({
|
|||||||
label,
|
label,
|
||||||
accent,
|
accent,
|
||||||
danger,
|
danger,
|
||||||
|
title,
|
||||||
}: {
|
}: {
|
||||||
onClick: (e?: React.MouseEvent) => void;
|
onClick: (e?: React.MouseEvent) => void;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
accent?: boolean;
|
accent?: boolean;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
let color = "text-[var(--text-secondary)] hover:text-[var(--text-primary)]";
|
let color = "text-[var(--text-secondary)] hover:text-[var(--text-primary)]";
|
||||||
if (accent) color = "text-[var(--accent)] hover:text-[var(--accent-hover)]";
|
if (accent) color = "text-[var(--accent)] hover:text-[var(--accent-hover)]";
|
||||||
@@ -1208,6 +1247,7 @@ function ActionButton({
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onClick(e); }}
|
onClick={(e) => { e.stopPropagation(); onClick(e); }}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
className={`text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-50 ${color} hover:bg-[var(--bg-primary)]`}
|
className={`text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-50 ${color} hover:bg-[var(--bg-primary)]`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -62,7 +62,15 @@ export default function SttButton({ state, error, onToggle, onCancel }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute bottom-2 left-2 z-50 flex items-center gap-2">
|
<div className="flex items-center gap-1.5">
|
||||||
|
{state === "recording" && (
|
||||||
|
<span className="text-[#f85149] font-mono">{formatTime(elapsed)}</span>
|
||||||
|
)}
|
||||||
|
{state === "error" && error && (
|
||||||
|
<span className="text-[#f85149] max-w-[180px] truncate" title={error}>
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@@ -71,44 +79,34 @@ export default function SttButton({ state, error, onToggle, onCancel }: Props) {
|
|||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
disabled={state === "transcribing"}
|
disabled={state === "transcribing"}
|
||||||
className={`w-8 h-8 rounded-full flex items-center justify-center transition-all cursor-pointer ${
|
className={`w-5 h-5 rounded-full flex items-center justify-center transition-all cursor-pointer ${
|
||||||
state === "recording"
|
state === "recording"
|
||||||
? "bg-[#f85149] text-white shadow-lg animate-pulse"
|
? "bg-[#f85149] text-white animate-pulse"
|
||||||
: state === "transcribing"
|
: state === "transcribing"
|
||||||
? "bg-[#1f2937] text-[#58a6ff] border border-[#30363d] opacity-80"
|
? "text-[#58a6ff] opacity-80"
|
||||||
: "bg-[#1f2937]/80 text-[#8b949e] border border-[#30363d] hover:text-[#e6edf3] hover:bg-[#2d3748]"
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{state === "transcribing" ? (
|
{state === "transcribing" ? (
|
||||||
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" opacity="0.25" />
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" opacity="0.25" />
|
||||||
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
||||||
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
|
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{hovered && state !== "recording" && (
|
{hovered && state !== "recording" && (
|
||||||
<div className="absolute bottom-full left-0 mb-1.5 px-2 py-1 text-[11px] leading-snug text-[#e6edf3] bg-[#21262d] border border-[#30363d] rounded shadow-lg whitespace-nowrap pointer-events-none">
|
<div className="absolute bottom-full right-0 mb-1.5 px-2 py-1 text-[11px] leading-snug text-[#e6edf3] bg-[#21262d] border border-[#30363d] rounded shadow-lg whitespace-nowrap pointer-events-none z-50">
|
||||||
{state === "transcribing" ? "Transcribing..." : (
|
{state === "transcribing" ? "Transcribing..." : (
|
||||||
<>Speech to text <kbd className="ml-1 px-1 py-0.5 text-[10px] bg-[#0d1117] border border-[#30363d] rounded font-mono">Ctrl+Shift+M</kbd></>
|
<>Speech to text <kbd className="ml-1 px-1 py-0.5 text-[10px] bg-[#0d1117] border border-[#30363d] rounded font-mono">Ctrl+Shift+M</kbd></>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{state === "recording" && (
|
|
||||||
<span className="text-xs text-[#f85149] font-mono bg-[#1f2937] px-2 py-0.5 rounded border border-[#30363d]">
|
|
||||||
{formatTime(elapsed)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{state === "error" && error && (
|
|
||||||
<span className="text-xs text-[#f85149] bg-[#1f2937] px-2 py-0.5 rounded border border-[#30363d] max-w-[200px] truncate">
|
|
||||||
{error}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import { openUrl } from "@tauri-apps/plugin-opener";
|
|||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
import { useSTT } from "../../hooks/useSTT";
|
import { awsSsoRefresh, uploadHostFileToTerminal } from "../../lib/tauri-commands";
|
||||||
import SttButton from "./SttButton";
|
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||||
import { awsSsoRefresh } from "../../lib/tauri-commands";
|
|
||||||
import { UrlDetector } from "../../lib/urlDetector";
|
import { UrlDetector } from "../../lib/urlDetector";
|
||||||
import UrlToast from "./UrlToast";
|
import UrlToast from "./UrlToast";
|
||||||
import { trimSelection } from "./trimSelection";
|
import { trimSelection } from "./trimSelection";
|
||||||
@@ -29,10 +28,8 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
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 setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
|
||||||
const sttEnabled = useAppState(s => s.appSettings?.stt?.enabled);
|
const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
|
||||||
const stt = useSTT(sessionId, sendInput);
|
const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
|
||||||
const sttToggleRef = useRef(stt.toggle);
|
|
||||||
sttToggleRef.current = stt.toggle;
|
|
||||||
|
|
||||||
const ssoBufferRef = useRef("");
|
const ssoBufferRef = useRef("");
|
||||||
const ssoTriggeredRef = useRef(false);
|
const ssoTriggeredRef = useRef(false);
|
||||||
@@ -51,6 +48,72 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
const autoFollowRef = useRef(true);
|
const autoFollowRef = useRef(true);
|
||||||
const lastUserScrollTimeRef = useRef(0);
|
const lastUserScrollTimeRef = useRef(0);
|
||||||
|
|
||||||
|
// Keep latest `active` readable inside long-lived listeners (drag-drop below,
|
||||||
|
// and the unmount-cleanup effect further down).
|
||||||
|
const activeRef = useRef(active);
|
||||||
|
activeRef.current = active;
|
||||||
|
|
||||||
|
// File drag-and-drop: dropped files are copied into the container and their
|
||||||
|
// in-container paths typed into the prompt so Claude Code can read them.
|
||||||
|
// Tauri intercepts OS file drops at the webview level, so we use
|
||||||
|
// onDragDropEvent (HTML5 ondrop on the element wouldn't expose file paths).
|
||||||
|
// The listener is window-wide, so we route purely by a hit-test against this
|
||||||
|
// terminal's bounds: the pane the drop lands on handles it. Inactive panes are
|
||||||
|
// `display:none` (zero-size rect) so they never match — this works for the
|
||||||
|
// current tabbed layout and would also do the right thing with split panes.
|
||||||
|
useEffect(() => {
|
||||||
|
let unlisten: (() => void) | undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const insideThisTerminal = (pos: { x: number; y: number }): boolean => {
|
||||||
|
const rect = containerRef.current?.getBoundingClientRect();
|
||||||
|
// A hidden (display:none) pane has a zero-size rect — never a drop target.
|
||||||
|
if (!rect || rect.width === 0 || rect.height === 0) return false;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const x = pos.x / dpr;
|
||||||
|
const y = pos.y / dpr;
|
||||||
|
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Always single-quote: a dropped filename can contain shell metacharacters
|
||||||
|
// ($(), &&, ', spaces) even with no whitespace, and this path is typed into
|
||||||
|
// a live shell. Single-quoting with '\'' escaping neutralizes all of them.
|
||||||
|
const quote = (p: string) => `'${p.replace(/'/g, "'\\''")}'`;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
|
||||||
|
if (event.payload.type !== "drop") return;
|
||||||
|
if (!insideThisTerminal(event.payload.position)) return;
|
||||||
|
|
||||||
|
const paths = event.payload.paths ?? [];
|
||||||
|
if (paths.length === 0) return;
|
||||||
|
|
||||||
|
setImagePasteMsg(`Adding ${paths.length} file${paths.length > 1 ? "s" : ""}…`);
|
||||||
|
const containerPaths: string[] = [];
|
||||||
|
for (const p of paths) {
|
||||||
|
try {
|
||||||
|
containerPaths.push(await uploadHostFileToTerminal(sessionId, p));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("File drop upload failed for", p, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (containerPaths.length === 0) {
|
||||||
|
setImagePasteMsg("File drop failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendInput(sessionId, containerPaths.map(quote).join(" ") + " ");
|
||||||
|
setImagePasteMsg(`Added ${containerPaths.length} file path${containerPaths.length > 1 ? "s" : ""}`);
|
||||||
|
});
|
||||||
|
if (cancelled) un();
|
||||||
|
else unlisten = un;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unlisten?.();
|
||||||
|
};
|
||||||
|
}, [sessionId, sendInput]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
@@ -111,9 +174,10 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
}
|
}
|
||||||
return false; // prevent xterm from processing this key
|
return false; // prevent xterm from processing this key
|
||||||
}
|
}
|
||||||
// Ctrl+Shift+M toggles speech-to-text recording
|
// Ctrl+Shift+M toggles speech-to-text recording (mic lives in the status
|
||||||
|
// bar, bound to the active session; trigger it via the store).
|
||||||
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "M") {
|
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "M") {
|
||||||
sttToggleRef.current();
|
useAppState.getState().sttToggle();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -319,6 +383,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
||||||
webglRef.current = null;
|
webglRef.current = null;
|
||||||
term.dispose();
|
term.dispose();
|
||||||
|
termRef.current = null;
|
||||||
};
|
};
|
||||||
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
@@ -393,6 +458,27 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Surface this terminal's scroll state to the status bar's "Jump to Current"
|
||||||
|
// control, but only while it's the active (visible) terminal.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
setTerminalAtBottom(isAtBottom);
|
||||||
|
setScrollActiveToBottom(handleScrollToBottom);
|
||||||
|
}, [active, isAtBottom, handleScrollToBottom, setTerminalAtBottom, setScrollActiveToBottom]);
|
||||||
|
|
||||||
|
// On unmount, if this was the active terminal, clear the status-bar scroll
|
||||||
|
// state so it doesn't point at a disposed terminal. (Tab switches don't
|
||||||
|
// unmount — the deactivating terminal stays mounted but hidden — so this
|
||||||
|
// only fires when the active session is actually closed.)
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (activeRef.current) {
|
||||||
|
setTerminalAtBottom(true);
|
||||||
|
setScrollActiveToBottom(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [setTerminalAtBottom, setScrollActiveToBottom]);
|
||||||
|
|
||||||
const writeSelection = useCallback((mode: "trimmed" | "raw") => {
|
const writeSelection = useCallback((mode: "trimmed" | "raw") => {
|
||||||
const term = termRef.current;
|
const term = termRef.current;
|
||||||
if (!term) return;
|
if (!term) return;
|
||||||
@@ -457,23 +543,19 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
>
|
>
|
||||||
{isAutoFollow ? "▼ Following" : "▽ Paused"}
|
{isAutoFollow ? "▼ Following" : "▽ Paused"}
|
||||||
</button>
|
</button>
|
||||||
{/* STT mic button - bottom left */}
|
{/* Padding lives on this wrapper, NOT on the xterm host element. xterm's
|
||||||
{sttEnabled && <SttButton state={stt.state} error={stt.error} onToggle={stt.toggle} onCancel={stt.cancelRecording} />}
|
FitAddon measures the host element it's mounted into; padding there
|
||||||
{/* Jump to Current - bottom right, when scrolled up */}
|
causes the grid to overhang and clip the rightmost column / bottom
|
||||||
{!isAtBottom && (
|
row. The host below fills this wrapper's content box with no padding.
|
||||||
<button
|
Kept to a tight, even gutter so the terminal claims as much area as
|
||||||
onClick={handleScrollToBottom}
|
possible while leaving a little breathing room beside the scrollbar. */}
|
||||||
className="absolute bottom-4 right-4 z-50 px-3 py-1.5 rounded-md text-xs font-medium bg-[#1f2937] text-[#58a6ff] border border-[#30363d] shadow-lg hover:bg-[#2d3748] transition-colors cursor-pointer"
|
<div className="w-full h-full" style={{ padding: "4px 8px 4px 8px" }}>
|
||||||
>
|
|
||||||
Jump to Current ↓
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
style={{ padding: "8px 12px 48px 16px" }}
|
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
{contextMenu && (
|
{contextMenu && (
|
||||||
<TerminalContextMenu
|
<TerminalContextMenu
|
||||||
x={contextMenu.x}
|
x={contextMenu.x}
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
|
|||||||
const streamRef = useRef<MediaStream | null>(null);
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
const workletRef = useRef<AudioWorkletNode | null>(null);
|
const workletRef = useRef<AudioWorkletNode | null>(null);
|
||||||
const chunksRef = useRef<Int16Array[]>([]);
|
const chunksRef = useRef<Int16Array[]>([]);
|
||||||
|
// Pin the transcript to the terminal that was active when recording STARTED.
|
||||||
|
// The hook is bound to the live active session, which can change mid-recording
|
||||||
|
// (the user switches tabs); without this the transcript would land in whatever
|
||||||
|
// tab is active at stop time.
|
||||||
|
const recordingSessionIdRef = useRef(sessionId);
|
||||||
|
|
||||||
const appSettings = useAppState((s) => s.appSettings);
|
const appSettings = useAppState((s) => s.appSettings);
|
||||||
const deviceId = appSettings?.default_microphone;
|
const deviceId = appSettings?.default_microphone;
|
||||||
@@ -22,6 +27,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
|
|||||||
setState("recording");
|
setState("recording");
|
||||||
setError(null);
|
setError(null);
|
||||||
chunksRef.current = [];
|
chunksRef.current = [];
|
||||||
|
recordingSessionIdRef.current = sessionId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const audioConstraints: MediaTrackConstraints = {
|
const audioConstraints: MediaTrackConstraints = {
|
||||||
@@ -57,7 +63,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
|
|||||||
setError(msg);
|
setError(msg);
|
||||||
setState("error");
|
setState("error");
|
||||||
}
|
}
|
||||||
}, [state, deviceId]);
|
}, [state, deviceId, sessionId]);
|
||||||
|
|
||||||
const stopRecording = useCallback(async () => {
|
const stopRecording = useCallback(async () => {
|
||||||
if (state !== "recording") return;
|
if (state !== "recording") return;
|
||||||
@@ -102,7 +108,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
|
|||||||
|
|
||||||
const text = await commands.transcribeAudio(audioData);
|
const text = await commands.transcribeAudio(audioData);
|
||||||
if (text) {
|
if (text) {
|
||||||
await sendInput(sessionId, text);
|
await sendInput(recordingSessionIdRef.current, text);
|
||||||
}
|
}
|
||||||
setState("idle");
|
setState("idle");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -112,7 +118,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
|
|||||||
// Reset to idle after a brief delay so the UI shows the error
|
// Reset to idle after a brief delay so the UI shows the error
|
||||||
setTimeout(() => setState("idle"), 3000);
|
setTimeout(() => setState("idle"), 3000);
|
||||||
}
|
}
|
||||||
}, [state, sessionId, sendInput]);
|
}, [state, sendInput]);
|
||||||
|
|
||||||
const cancelRecording = useCallback(async () => {
|
const cancelRecording = useCallback(async () => {
|
||||||
workletRef.current?.disconnect();
|
workletRef.current?.disconnect();
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export const closeTerminalSession = (sessionId: string) =>
|
|||||||
invoke<void>("close_terminal_session", { sessionId });
|
invoke<void>("close_terminal_session", { sessionId });
|
||||||
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
|
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
|
||||||
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
|
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
|
||||||
|
export const uploadHostFileToTerminal = (sessionId: string, hostPath: string) =>
|
||||||
|
invoke<string>("upload_host_file_to_terminal", { sessionId, hostPath });
|
||||||
export const startAudioBridge = (sessionId: string) =>
|
export const startAudioBridge = (sessionId: string) =>
|
||||||
invoke<void>("start_audio_bridge", { sessionId });
|
invoke<void>("start_audio_bridge", { sessionId });
|
||||||
export const sendAudioData = (sessionId: string, data: number[]) =>
|
export const sendAudioData = (sessionId: string, data: number[]) =>
|
||||||
@@ -76,6 +78,8 @@ export const listContainerFiles = (projectId: string, path: string) =>
|
|||||||
invoke<FileEntry[]>("list_container_files", { projectId, path });
|
invoke<FileEntry[]>("list_container_files", { projectId, path });
|
||||||
export const downloadContainerFile = (projectId: string, containerPath: string, hostPath: string) =>
|
export const downloadContainerFile = (projectId: string, containerPath: string, hostPath: string) =>
|
||||||
invoke<void>("download_container_file", { projectId, containerPath, hostPath });
|
invoke<void>("download_container_file", { projectId, containerPath, hostPath });
|
||||||
|
export const downloadContainerBackup = (projectId: string, hostPath: string, containerPath?: string) =>
|
||||||
|
invoke<number>("download_container_backup", { projectId, hostPath, containerPath });
|
||||||
export const uploadFileToContainer = (projectId: string, hostPath: string, containerDir: string) =>
|
export const uploadFileToContainer = (projectId: string, hostPath: string, containerDir: string) =>
|
||||||
invoke<void>("upload_file_to_container", { projectId, hostPath, containerDir });
|
invoke<void>("upload_file_to_container", { projectId, hostPath, containerDir });
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ interface AppState {
|
|||||||
// UI state
|
// UI state
|
||||||
terminalHasSelection: boolean;
|
terminalHasSelection: boolean;
|
||||||
setTerminalHasSelection: (has: boolean) => void;
|
setTerminalHasSelection: (has: boolean) => void;
|
||||||
|
// STT toggle for the active session, registered by App so the terminal's
|
||||||
|
// Ctrl+Shift+M shortcut can trigger the single status-bar mic instance.
|
||||||
|
sttToggle: () => void;
|
||||||
|
setSttToggle: (fn: () => void) => void;
|
||||||
|
// Active terminal scroll state, surfaced so the status bar can host the
|
||||||
|
// "Jump to Current" control. Only the active TerminalView writes these.
|
||||||
|
terminalAtBottom: boolean;
|
||||||
|
setTerminalAtBottom: (v: boolean) => void;
|
||||||
|
scrollActiveToBottom: () => void;
|
||||||
|
setScrollActiveToBottom: (fn: () => void) => void;
|
||||||
sidebarView: "projects" | "mcp" | "settings";
|
sidebarView: "projects" | "mcp" | "settings";
|
||||||
setSidebarView: (view: "projects" | "mcp" | "settings") => void;
|
setSidebarView: (view: "projects" | "mcp" | "settings") => void;
|
||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
@@ -125,6 +135,12 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
// UI state
|
// UI state
|
||||||
terminalHasSelection: false,
|
terminalHasSelection: false,
|
||||||
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
|
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
|
||||||
|
sttToggle: () => {},
|
||||||
|
setSttToggle: (fn) => set({ sttToggle: fn }),
|
||||||
|
terminalAtBottom: true,
|
||||||
|
setTerminalAtBottom: (v) => set({ terminalAtBottom: v }),
|
||||||
|
scrollActiveToBottom: () => {},
|
||||||
|
setScrollActiveToBottom: (fn) => set({ scrollActiveToBottom: fn }),
|
||||||
sidebarView: "projects",
|
sidebarView: "projects",
|
||||||
setSidebarView: (view) => set({ sidebarView: view }),
|
setSidebarView: (view) => set({ sidebarView: view }),
|
||||||
sidebarCollapsed: loadSidebarCollapsed(),
|
sidebarCollapsed: loadSidebarCollapsed(),
|
||||||
|
|||||||
+16
-3
@@ -212,10 +212,14 @@ if [ -n "$CLAUDE_CODE_SETTINGS_JSON" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ── AWS SSO auth refresh command ──────────────────────────────────────────────
|
# ── AWS SSO auth refresh command ──────────────────────────────────────────────
|
||||||
# When set, inject awsAuthRefresh into ~/.claude.json so Claude Code calls
|
# When set (Bedrock + profile/SSO auth), inject awsAuthRefresh into
|
||||||
# triple-c-sso-refresh when AWS credentials expire mid-session.
|
# ~/.claude.json so Claude Code calls triple-c-sso-refresh when AWS credentials
|
||||||
if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
|
# expire mid-session. When NOT set, strip any awsAuthRefresh left behind by a
|
||||||
|
# previous Bedrock-profile session — ~/.claude.json lives in the persisted home
|
||||||
|
# volume, so without this the container keeps trying to run the SSO refresh even
|
||||||
|
# after switching to a non-SSO backend (Anthropic/Ollama) or to static creds.
|
||||||
CLAUDE_JSON="/home/claude/.claude.json"
|
CLAUDE_JSON="/home/claude/.claude.json"
|
||||||
|
if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
|
||||||
if [ -f "$CLAUDE_JSON" ]; then
|
if [ -f "$CLAUDE_JSON" ]; then
|
||||||
MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null)
|
MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null)
|
||||||
if [ -n "$MERGED" ]; then
|
if [ -n "$MERGED" ]; then
|
||||||
@@ -227,6 +231,15 @@ if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
|
|||||||
chown claude:claude "$CLAUDE_JSON"
|
chown claude:claude "$CLAUDE_JSON"
|
||||||
chmod 600 "$CLAUDE_JSON"
|
chmod 600 "$CLAUDE_JSON"
|
||||||
unset AWS_SSO_AUTH_REFRESH_CMD
|
unset AWS_SSO_AUTH_REFRESH_CMD
|
||||||
|
elif [ -f "$CLAUDE_JSON" ] && grep -q '"awsAuthRefresh"' "$CLAUDE_JSON" 2>/dev/null; then
|
||||||
|
# Only rewrite when the key is actually present, to avoid a needless jq
|
||||||
|
# reformat of ~/.claude.json on every start of a non-SSO backend.
|
||||||
|
MERGED=$(jq 'del(.awsAuthRefresh)' "$CLAUDE_JSON" 2>/dev/null)
|
||||||
|
if [ -n "$MERGED" ]; then
|
||||||
|
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
|
||||||
|
chown claude:claude "$CLAUDE_JSON"
|
||||||
|
chmod 600 "$CLAUDE_JSON"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Docker socket permissions ────────────────────────────────────────────────
|
# ── Docker socket permissions ────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user