Compare commits
11 Commits
v0.1.81-wi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bf8ef3dba1 | |||
| 418afe00ed | |||
| ab16ac11e7 | |||
| 429acd2fb5 | |||
| c853f2676d | |||
| 090aad6bc6 | |||
| c023d80c86 | |||
| 33f02e65c0 | |||
| c5e28f9caa | |||
| 86176d8830 | |||
| 58a10c65e9 |
17
app/public/audio-capture-processor.js
Normal file
17
app/public/audio-capture-processor.js
Normal file
@@ -0,0 +1,17 @@
|
||||
class AudioCaptureProcessor extends AudioWorkletProcessor {
|
||||
process(inputs, outputs, parameters) {
|
||||
const input = inputs[0];
|
||||
if (input && input.length > 0 && input[0].length > 0) {
|
||||
const samples = input[0]; // Float32Array, mono channel
|
||||
const int16 = new Int16Array(samples.length);
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, samples[i]));
|
||||
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||||
}
|
||||
this.port.postMessage(int16.buffer, [int16.buffer]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('audio-capture-processor', AudioCaptureProcessor);
|
||||
211
app/src-tauri/src/commands/file_commands.rs
Normal file
211
app/src-tauri/src/commands/file_commands.rs
Normal file
@@ -0,0 +1,211 @@
|
||||
use bollard::container::{DownloadFromContainerOptions, UploadToContainerOptions};
|
||||
use futures_util::StreamExt;
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
use crate::docker::client::get_docker;
|
||||
use crate::docker::exec::exec_oneshot;
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FileEntry {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub is_directory: bool,
|
||||
pub size: u64,
|
||||
pub modified: String,
|
||||
pub permissions: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_container_files(
|
||||
project_id: String,
|
||||
path: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<FileEntry>, 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(|| "Container not running".to_string())?;
|
||||
|
||||
let cmd = vec![
|
||||
"find".to_string(),
|
||||
path.clone(),
|
||||
"-mindepth".to_string(),
|
||||
"1".to_string(),
|
||||
"-maxdepth".to_string(),
|
||||
"1".to_string(),
|
||||
"-printf".to_string(),
|
||||
"%f\t%y\t%s\t%T@\t%m\n".to_string(),
|
||||
];
|
||||
|
||||
let output = exec_oneshot(container_id, cmd).await?;
|
||||
|
||||
let mut entries: Vec<FileEntry> = output
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
let name = parts[0].to_string();
|
||||
let is_directory = parts[1] == "d";
|
||||
let size = parts[2].parse::<u64>().unwrap_or(0);
|
||||
let modified_epoch = parts[3].parse::<f64>().unwrap_or(0.0);
|
||||
let permissions = parts[4].to_string();
|
||||
|
||||
// Convert epoch to ISO-ish string
|
||||
let modified = {
|
||||
let secs = modified_epoch as i64;
|
||||
let dt = chrono::DateTime::from_timestamp(secs, 0)
|
||||
.unwrap_or_default();
|
||||
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
};
|
||||
|
||||
let entry_path = if path.ends_with('/') {
|
||||
format!("{}{}", path, name)
|
||||
} else {
|
||||
format!("{}/{}", path, name)
|
||||
};
|
||||
|
||||
Some(FileEntry {
|
||||
name,
|
||||
path: entry_path,
|
||||
is_directory,
|
||||
size,
|
||||
modified,
|
||||
permissions,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort: directories first, then alphabetical
|
||||
entries.sort_by(|a, b| {
|
||||
b.is_directory
|
||||
.cmp(&a.is_directory)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_container_file(
|
||||
project_id: String,
|
||||
container_path: String,
|
||||
host_path: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), 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(|| "Container not running".to_string())?;
|
||||
|
||||
let docker = get_docker()?;
|
||||
|
||||
let mut stream = docker.download_from_container(
|
||||
container_id,
|
||||
Some(DownloadFromContainerOptions {
|
||||
path: container_path.clone(),
|
||||
}),
|
||||
);
|
||||
|
||||
let mut tar_bytes = Vec::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| format!("Failed to download file: {}", e))?;
|
||||
tar_bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
// Extract single file from tar archive
|
||||
let mut archive = tar::Archive::new(&tar_bytes[..]);
|
||||
let mut found = false;
|
||||
for entry in archive
|
||||
.entries()
|
||||
.map_err(|e| format!("Failed to read tar entries: {}", e))?
|
||||
{
|
||||
let mut entry = entry.map_err(|e| format!("Failed to read tar entry: {}", e))?;
|
||||
let mut contents = Vec::new();
|
||||
std::io::Read::read_to_end(&mut entry, &mut contents)
|
||||
.map_err(|e| format!("Failed to read file contents: {}", e))?;
|
||||
std::fs::write(&host_path, &contents)
|
||||
.map_err(|e| format!("Failed to write file to host: {}", e))?;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if !found {
|
||||
return Err("File not found in tar archive".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload_file_to_container(
|
||||
project_id: String,
|
||||
host_path: String,
|
||||
container_dir: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), 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(|| "Container not running".to_string())?;
|
||||
|
||||
let docker = get_docker()?;
|
||||
|
||||
let file_data = std::fs::read(&host_path)
|
||||
.map_err(|e| format!("Failed to read host file: {}", e))?;
|
||||
|
||||
let file_name = std::path::Path::new(&host_path)
|
||||
.file_name()
|
||||
.ok_or_else(|| "Invalid file path".to_string())?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
// Build tar archive in memory
|
||||
let mut tar_buf = Vec::new();
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(file_data.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, &file_name, &file_data[..])
|
||||
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||
builder
|
||||
.finish()
|
||||
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||
}
|
||||
|
||||
docker
|
||||
.upload_to_container(
|
||||
container_id,
|
||||
Some(UploadToContainerOptions {
|
||||
path: container_dir,
|
||||
..Default::default()
|
||||
}),
|
||||
tar_buf.into(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod docker_commands;
|
||||
pub mod file_commands;
|
||||
pub mod mcp_commands;
|
||||
pub mod project_commands;
|
||||
pub mod settings_commands;
|
||||
|
||||
@@ -73,6 +73,7 @@ exec claude --dangerously-skip-permissions
|
||||
pub async fn open_terminal_session(
|
||||
project_id: String,
|
||||
session_id: String,
|
||||
session_type: Option<String>,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
@@ -86,7 +87,10 @@ pub async fn open_terminal_session(
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
let cmd = build_terminal_cmd(&project, &state);
|
||||
let cmd = match session_type.as_deref() {
|
||||
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
|
||||
_ => build_terminal_cmd(&project, &state),
|
||||
};
|
||||
|
||||
let output_event = format!("terminal-output-{}", session_id);
|
||||
let exit_event = format!("terminal-exit-{}", session_id);
|
||||
@@ -133,6 +137,10 @@ pub async fn close_terminal_session(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
// Close audio bridge if it exists
|
||||
let audio_session_id = format!("audio-{}", session_id);
|
||||
state.exec_manager.close_session(&audio_session_id).await;
|
||||
// Close terminal session
|
||||
state.exec_manager.close_session(&session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -156,3 +164,53 @@ pub async fn paste_image_to_terminal(
|
||||
.write_file_to_container(&container_id, &file_name, &image_data)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_audio_bridge(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
// Get container_id from the terminal session
|
||||
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||
|
||||
// Create audio bridge exec session with ID "audio-{session_id}"
|
||||
// The loop handles reconnection when the FIFO reader (fake rec) is killed and restarted
|
||||
let audio_session_id = format!("audio-{}", session_id);
|
||||
let cmd = vec![
|
||||
"bash".to_string(),
|
||||
"-c".to_string(),
|
||||
"FIFO=/tmp/triple-c-audio-input; [ -p \"$FIFO\" ] || mkfifo \"$FIFO\"; trap '' PIPE; while true; do cat > \"$FIFO\" 2>/dev/null; sleep 0.1; done".to_string(),
|
||||
];
|
||||
|
||||
state
|
||||
.exec_manager
|
||||
.create_session_with_tty(
|
||||
&container_id,
|
||||
&audio_session_id,
|
||||
cmd,
|
||||
false,
|
||||
|_data| { /* ignore output from the audio bridge */ },
|
||||
Box::new(|| { /* no exit handler needed */ }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_audio_data(
|
||||
session_id: String,
|
||||
data: Vec<u8>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let audio_session_id = format!("audio-{}", session_id);
|
||||
state.exec_manager.send_input(&audio_session_id, data).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_audio_bridge(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let audio_session_id = format!("audio-{}", session_id);
|
||||
state.exec_manager.close_session(&audio_session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -40,6 +40,54 @@ After tasks run, check notifications with `triple-c-scheduler notifications` and
|
||||
### Timezone
|
||||
Scheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used."#;
|
||||
|
||||
const MISSION_CONTROL_GLOBAL_INSTRUCTIONS: &str = r#"## Mission Control
|
||||
|
||||
The `/workspace/mission-control/` directory contains **Flight Control** — an AI-first development methodology for structured project management. Use it for all project work.
|
||||
|
||||
### How It Works
|
||||
|
||||
- **Mission Control is a tool, not a project.** It provides skills and methodology for managing other projects.
|
||||
- All Flight Control skills live in `/workspace/mission-control/.claude/skills/`
|
||||
- The projects registry at `/workspace/mission-control/projects.md` lists all active projects
|
||||
|
||||
### When to Use
|
||||
|
||||
When working on any project that has a `.flightops/` directory, follow the Flight Control methodology:
|
||||
1. Read the project's `.flightops/ARTIFACTS.md` to understand artifact storage
|
||||
2. Read `.flightops/FLIGHT_OPERATIONS.md` for the implementation workflow
|
||||
3. Use Mission Control skills for planning and execution
|
||||
|
||||
### Available Skills
|
||||
|
||||
| Skill | When to Use |
|
||||
|-------|-------------|
|
||||
| `/init-project` | Setting up a new project for Flight Control |
|
||||
| `/mission` | Defining new work outcomes (days-to-weeks scope) |
|
||||
| `/flight` | Creating technical specs from missions (hours-to-days scope) |
|
||||
| `/leg` | Generating implementation steps from flights (minutes-to-hours scope) |
|
||||
| `/agentic-workflow` | Executing legs with multi-agent workflow (implement, review, commit) |
|
||||
| `/flight-debrief` | Post-flight analysis after a flight lands |
|
||||
| `/mission-debrief` | Post-mission retrospective after completion |
|
||||
| `/daily-briefing` | Cross-project status report |
|
||||
|
||||
### Key Rules
|
||||
|
||||
- **Planning skills produce artifacts only** — never modify source code directly
|
||||
- **Phase gates require human confirmation** — missions before flights, flights before legs
|
||||
- **Legs are immutable once in-flight** — create new ones instead of modifying
|
||||
- **`/agentic-workflow` orchestrates implementation** — it spawns separate Developer and Reviewer agents
|
||||
- **Artifacts live in the target project** — not in mission-control"#;
|
||||
|
||||
const MISSION_CONTROL_PROJECT_INSTRUCTIONS: &str = r#"## Flight Operations
|
||||
|
||||
This project uses [Flight Control](https://github.com/msieurthenardier/mission-control) for structured development.
|
||||
|
||||
**Before any mission/flight/leg work, read these files in order:**
|
||||
1. `.flightops/README.md` — What the flightops directory contains
|
||||
2. `.flightops/FLIGHT_OPERATIONS.md` — **The workflow you MUST follow**
|
||||
3. `.flightops/ARTIFACTS.md` — Where all artifacts are stored
|
||||
4. `.flightops/agent-crews/` — Project crew definitions for each phase (read the relevant crew file)"#;
|
||||
|
||||
/// Build the full CLAUDE_INSTRUCTIONS value by merging global + project
|
||||
/// instructions, appending port mapping docs, and appending scheduler docs.
|
||||
/// Used by both create_container() and container_needs_recreation() to ensure
|
||||
@@ -48,8 +96,13 @@ fn build_claude_instructions(
|
||||
global_instructions: Option<&str>,
|
||||
project_instructions: Option<&str>,
|
||||
port_mappings: &[PortMapping],
|
||||
mission_control_enabled: bool,
|
||||
) -> Option<String> {
|
||||
let mut combined = merge_claude_instructions(global_instructions, project_instructions);
|
||||
let mut combined = merge_claude_instructions(
|
||||
global_instructions,
|
||||
project_instructions,
|
||||
mission_control_enabled,
|
||||
);
|
||||
|
||||
if !port_mappings.is_empty() {
|
||||
let mut port_lines: Vec<String> = Vec::new();
|
||||
@@ -116,14 +169,37 @@ fn merge_custom_env_vars(global: &[EnvVar], project: &[EnvVar]) -> Vec<EnvVar> {
|
||||
}
|
||||
|
||||
/// Merge global and per-project Claude instructions into a single string.
|
||||
/// When mission_control_enabled is true, appends Mission Control global
|
||||
/// instructions after global and project instructions after project.
|
||||
fn merge_claude_instructions(
|
||||
global_instructions: Option<&str>,
|
||||
project_instructions: Option<&str>,
|
||||
mission_control_enabled: bool,
|
||||
) -> Option<String> {
|
||||
match (global_instructions, project_instructions) {
|
||||
// Build the global portion (user global + optional MC global)
|
||||
let global_part = if mission_control_enabled {
|
||||
match global_instructions {
|
||||
Some(g) => Some(format!("{}\n\n{}", g, MISSION_CONTROL_GLOBAL_INSTRUCTIONS)),
|
||||
None => Some(MISSION_CONTROL_GLOBAL_INSTRUCTIONS.to_string()),
|
||||
}
|
||||
} else {
|
||||
global_instructions.map(|g| g.to_string())
|
||||
};
|
||||
|
||||
// Build the project portion (user project + optional MC project)
|
||||
let project_part = if mission_control_enabled {
|
||||
match project_instructions {
|
||||
Some(p) => Some(format!("{}\n\n{}", p, MISSION_CONTROL_PROJECT_INSTRUCTIONS)),
|
||||
None => Some(MISSION_CONTROL_PROJECT_INSTRUCTIONS.to_string()),
|
||||
}
|
||||
} else {
|
||||
project_instructions.map(|p| p.to_string())
|
||||
};
|
||||
|
||||
match (global_part, project_part) {
|
||||
(Some(g), Some(p)) => Some(format!("{}\n\n{}", g, p)),
|
||||
(Some(g), None) => Some(g.to_string()),
|
||||
(None, Some(p)) => Some(p.to_string()),
|
||||
(Some(g), None) => Some(g),
|
||||
(None, Some(p)) => Some(p),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
@@ -426,11 +502,17 @@ pub async fn create_container(
|
||||
}
|
||||
}
|
||||
|
||||
// Mission Control env var
|
||||
if project.mission_control_enabled {
|
||||
env_vars.push("MISSION_CONTROL_ENABLED=1".to_string());
|
||||
}
|
||||
|
||||
// Claude instructions (global + per-project, plus port mapping info + scheduler docs)
|
||||
let combined_instructions = build_claude_instructions(
|
||||
global_claude_instructions,
|
||||
project.claude_instructions.as_deref(),
|
||||
&project.port_mappings,
|
||||
project.mission_control_enabled,
|
||||
);
|
||||
|
||||
if let Some(ref instructions) = combined_instructions {
|
||||
@@ -567,6 +649,7 @@ pub async fn create_container(
|
||||
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.mcp-fingerprint".to_string(), compute_mcp_fingerprint(mcp_servers));
|
||||
labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string());
|
||||
|
||||
let host_config = HostConfig {
|
||||
mounts: Some(mounts),
|
||||
@@ -885,11 +968,20 @@ pub async fn container_needs_recreation(
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// ── Mission Control ────────────────────────────────────────────────────
|
||||
let expected_mc = project.mission_control_enabled.to_string();
|
||||
let container_mc = get_label("triple-c.mission-control").unwrap_or_else(|| "false".to_string());
|
||||
if container_mc != expected_mc {
|
||||
log::info!("Mission Control mismatch (container={:?}, expected={:?})", container_mc, expected_mc);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// ── Claude instructions ───────────────────────────────────────────────
|
||||
let expected_instructions = build_claude_instructions(
|
||||
global_claude_instructions,
|
||||
project.claude_instructions.as_deref(),
|
||||
&project.port_mappings,
|
||||
project.mission_control_enabled,
|
||||
);
|
||||
let container_instructions = get_env("CLAUDE_INSTRUCTIONS");
|
||||
if container_instructions.as_deref() != expected_instructions.as_deref() {
|
||||
|
||||
@@ -60,6 +60,22 @@ impl ExecSessionManager {
|
||||
on_output: F,
|
||||
on_exit: Box<dyn FnOnce() + Send>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(Vec<u8>) + Send + 'static,
|
||||
{
|
||||
self.create_session_with_tty(container_id, session_id, cmd, true, on_output, on_exit)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_session_with_tty<F>(
|
||||
&self,
|
||||
container_id: &str,
|
||||
session_id: &str,
|
||||
cmd: Vec<String>,
|
||||
tty: bool,
|
||||
on_output: F,
|
||||
on_exit: Box<dyn FnOnce() + Send>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(Vec<u8>) + Send + 'static,
|
||||
{
|
||||
@@ -72,7 +88,7 @@ impl ExecSessionManager {
|
||||
attach_stdin: Some(true),
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
tty: Some(true),
|
||||
tty: Some(tty),
|
||||
cmd: Some(cmd),
|
||||
user: Some("claude".to_string()),
|
||||
working_dir: Some("/workspace".to_string()),
|
||||
@@ -261,3 +277,41 @@ impl ExecSessionManager {
|
||||
Ok(format!("/tmp/{}", file_name))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let exec = docker
|
||||
.create_exec(
|
||||
container_id,
|
||||
CreateExecOptions {
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
cmd: Some(cmd),
|
||||
user: Some("claude".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create exec: {}", e))?;
|
||||
|
||||
let result = docker
|
||||
.start_exec(&exec.id, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
||||
|
||||
match result {
|
||||
StartExecResults::Attached { mut output, .. } => {
|
||||
let mut stdout = String::new();
|
||||
while let Some(msg) = output.next().await {
|
||||
match msg {
|
||||
Ok(data) => stdout.push_str(&String::from_utf8_lossy(&data.into_bytes())),
|
||||
Err(e) => return Err(format!("Exec output error: {}", e)),
|
||||
}
|
||||
}
|
||||
Ok(stdout)
|
||||
}
|
||||
StartExecResults::Detached => Err("Exec started in detached mode".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,13 @@ pub fn run() {
|
||||
commands::terminal_commands::terminal_resize,
|
||||
commands::terminal_commands::close_terminal_session,
|
||||
commands::terminal_commands::paste_image_to_terminal,
|
||||
commands::terminal_commands::start_audio_bridge,
|
||||
commands::terminal_commands::send_audio_data,
|
||||
commands::terminal_commands::stop_audio_bridge,
|
||||
// Files
|
||||
commands::file_commands::list_container_files,
|
||||
commands::file_commands::download_container_file,
|
||||
commands::file_commands::upload_file_to_container,
|
||||
// MCP
|
||||
commands::mcp_commands::list_mcp_servers,
|
||||
commands::mcp_commands::add_mcp_server,
|
||||
|
||||
@@ -70,6 +70,8 @@ pub struct AppSettings {
|
||||
pub dismissed_update_version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub timezone: Option<String>,
|
||||
#[serde(default)]
|
||||
pub default_microphone: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
@@ -87,6 +89,7 @@ impl Default for AppSettings {
|
||||
auto_check_updates: true,
|
||||
dismissed_update_version: None,
|
||||
timezone: None,
|
||||
default_microphone: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,10 @@ pub struct Project {
|
||||
pub auth_mode: AuthMode,
|
||||
pub bedrock_config: Option<BedrockConfig>,
|
||||
pub allow_docker_access: bool,
|
||||
#[serde(default)]
|
||||
pub mission_control_enabled: bool,
|
||||
pub ssh_key_path: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub git_token: Option<String>,
|
||||
pub git_user_name: Option<String>,
|
||||
pub git_user_email: Option<String>,
|
||||
@@ -100,14 +102,14 @@ impl Default for BedrockAuthMethod {
|
||||
pub struct BedrockConfig {
|
||||
pub auth_method: BedrockAuthMethod,
|
||||
pub aws_region: String,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub aws_access_key_id: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub aws_secret_access_key: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub aws_session_token: Option<String>,
|
||||
pub aws_profile: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub aws_bearer_token: Option<String>,
|
||||
pub model_id: Option<String>,
|
||||
pub disable_prompt_caching: bool,
|
||||
@@ -125,6 +127,7 @@ impl Project {
|
||||
auth_mode: AuthMode::default(),
|
||||
bedrock_config: None,
|
||||
allow_docker_access: false,
|
||||
mission_control_enabled: false,
|
||||
ssh_key_path: None,
|
||||
git_token: None,
|
||||
git_user_name: None,
|
||||
|
||||
55
app/src/components/projects/ConfirmRemoveModal.tsx
Normal file
55
app/src/components/projects/ConfirmRemoveModal.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
|
||||
interface Props {
|
||||
projectName: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onCancel]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current) onCancel();
|
||||
},
|
||||
[onCancel],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[24rem] shadow-xl">
|
||||
<h2 className="text-lg font-semibold mb-3">Remove Project</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-5">
|
||||
Are you sure you want to remove <strong className="text-[var(--text-primary)]">{projectName}</strong>? This will delete the container, config volume, and stored credentials.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 text-sm text-white bg-[var(--error)] hover:opacity-80 rounded transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
app/src/components/projects/FileManagerModal.tsx
Normal file
197
app/src/components/projects/FileManagerModal.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useFileManager } from "../../hooks/useFileManager";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
export default function FileManagerModal({ projectId, projectName, onClose }: Props) {
|
||||
const {
|
||||
currentPath,
|
||||
entries,
|
||||
loading,
|
||||
error,
|
||||
navigate,
|
||||
goUp,
|
||||
refresh,
|
||||
downloadFile,
|
||||
uploadFile,
|
||||
} = useFileManager(projectId);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load initial directory
|
||||
useEffect(() => {
|
||||
navigate("/workspace");
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
// Build breadcrumbs from current path
|
||||
const breadcrumbs = currentPath === "/"
|
||||
? [{ label: "/", path: "/" }]
|
||||
: currentPath.split("/").reduce<{ label: string; path: string }[]>((acc, part, i) => {
|
||||
if (i === 0) {
|
||||
acc.push({ label: "/", path: "/" });
|
||||
} else if (part) {
|
||||
const parentPath = acc[acc.length - 1].path;
|
||||
const fullPath = parentPath === "/" ? `/${part}` : `${parentPath}/${part}`;
|
||||
acc.push({ label: part, path: fullPath });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg shadow-xl w-[36rem] max-h-[80vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border-color)]">
|
||||
<h2 className="text-sm font-semibold">Files — {projectName}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Path bar */}
|
||||
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--border-color)] text-xs overflow-x-auto flex-shrink-0">
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
<span key={crumb.path} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
|
||||
<button
|
||||
onClick={() => navigate(crumb.path)}
|
||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors whitespace-nowrap"
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50 px-1"
|
||||
title="Refresh"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{error && (
|
||||
<div className="px-4 py-2 text-xs text-[var(--error)]">{error}</div>
|
||||
)}
|
||||
|
||||
{loading && entries.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-xs text-[var(--text-secondary)]">
|
||||
Loading...
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{/* Go up entry */}
|
||||
{currentPath !== "/" && (
|
||||
<tr
|
||||
onClick={() => goUp()}
|
||||
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<td className="px-4 py-1.5 text-[var(--text-primary)]">..</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
{entries.map((entry) => (
|
||||
<tr
|
||||
key={entry.name}
|
||||
onClick={() => entry.is_directory && navigate(entry.path)}
|
||||
className={`${
|
||||
entry.is_directory ? "cursor-pointer" : ""
|
||||
} hover:bg-[var(--bg-tertiary)] transition-colors`}
|
||||
>
|
||||
<td className="px-4 py-1.5">
|
||||
<span className={entry.is_directory ? "text-[var(--accent)]" : "text-[var(--text-primary)]"}>
|
||||
{entry.is_directory ? "📁 " : ""}{entry.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap">
|
||||
{!entry.is_directory && formatSize(entry.size)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
|
||||
{entry.modified}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{!entry.is_directory && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadFile(entry);
|
||||
}}
|
||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors px-1"
|
||||
title="Download"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{entries.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-[var(--text-secondary)]">
|
||||
Empty directory
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border-color)]">
|
||||
<button
|
||||
onClick={uploadFile}
|
||||
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||||
>
|
||||
Upload file
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import EnvVarsModal from "./EnvVarsModal";
|
||||
import PortMappingsModal from "./PortMappingsModal";
|
||||
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
||||
import ContainerProgressModal from "./ContainerProgressModal";
|
||||
import FileManagerModal from "./FileManagerModal";
|
||||
import ConfirmRemoveModal from "./ConfirmRemoveModal";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
@@ -27,9 +29,13 @@ export default function ProjectCard({ project }: Props) {
|
||||
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
|
||||
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
||||
const [showFileManager, setShowFileManager] = useState(false);
|
||||
const [progressMsg, setProgressMsg] = useState<string | null>(null);
|
||||
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
|
||||
const [operationCompleted, setOperationCompleted] = useState(false);
|
||||
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [editName, setEditName] = useState(project.name);
|
||||
const isSelected = selectedProjectId === project.id;
|
||||
const isStopped = project.status === "stopped" || project.status === "error";
|
||||
|
||||
@@ -54,6 +60,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
|
||||
// Sync local state when project prop changes (e.g., after save or external update)
|
||||
useEffect(() => {
|
||||
setEditName(project.name);
|
||||
setPaths(project.paths ?? []);
|
||||
setSshKeyPath(project.ssh_key_path ?? "");
|
||||
setGitName(project.git_user_name ?? "");
|
||||
@@ -135,6 +142,14 @@ export default function ProjectCard({ project }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenBashShell = async () => {
|
||||
try {
|
||||
await openTerminal(project.id, project.name, "bash");
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceStop = async () => {
|
||||
try {
|
||||
await stop(project.id);
|
||||
@@ -309,7 +324,41 @@ export default function ProjectCard({ project }: Props) {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${statusColor}`} />
|
||||
<span className="text-sm font-medium truncate flex-1">{project.name}</span>
|
||||
{isEditingName ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onBlur={async () => {
|
||||
setIsEditingName(false);
|
||||
const trimmed = editName.trim();
|
||||
if (trimmed && trimmed !== project.name) {
|
||||
try {
|
||||
await update({ ...project, name: trimmed });
|
||||
} catch (err) {
|
||||
console.error("Failed to rename project:", err);
|
||||
setEditName(project.name);
|
||||
}
|
||||
} else {
|
||||
setEditName(project.name);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
if (e.key === "Escape") { setEditName(project.name); setIsEditingName(false); }
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-sm font-medium flex-1 min-w-0 px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded text-[var(--text-primary)] focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-sm font-medium truncate flex-1 cursor-text"
|
||||
title="Double-click to rename"
|
||||
onDoubleClick={(e) => { e.stopPropagation(); setIsEditingName(true); }}
|
||||
>
|
||||
{project.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 ml-4 space-y-0.5">
|
||||
{project.paths.map((pp, i) => (
|
||||
@@ -371,6 +420,8 @@ export default function ProjectCard({ project }: Props) {
|
||||
<>
|
||||
<ActionButton onClick={handleStop} disabled={loading} label="Stop" />
|
||||
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
|
||||
<ActionButton onClick={handleOpenBashShell} disabled={loading} label="Shell" />
|
||||
<ActionButton onClick={() => setShowFileManager(true)} disabled={loading} label="Files" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -386,11 +437,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
label={showConfig ? "Hide" : "Config"}
|
||||
/>
|
||||
<ActionButton
|
||||
onClick={async () => {
|
||||
if (confirm(`Remove project "${project.name}"?`)) {
|
||||
await remove(project.id);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowRemoveModal(true)}
|
||||
disabled={loading}
|
||||
label="Remove"
|
||||
danger
|
||||
@@ -576,6 +623,28 @@ export default function ProjectCard({ project }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mission Control toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-[var(--text-secondary)]">Mission Control</label>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await update({ ...project, mission_control_enabled: !project.mission_control_enabled });
|
||||
} catch (err) {
|
||||
console.error("Failed to update Mission Control setting:", err);
|
||||
}
|
||||
}}
|
||||
disabled={!isStopped}
|
||||
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${
|
||||
project.mission_control_enabled
|
||||
? "bg-[var(--success)] text-white"
|
||||
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{project.mission_control_enabled ? "ON" : "OFF"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-[var(--text-secondary)]">
|
||||
@@ -827,6 +896,25 @@ export default function ProjectCard({ project }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showFileManager && (
|
||||
<FileManagerModal
|
||||
projectId={project.id}
|
||||
projectName={project.name}
|
||||
onClose={() => setShowFileManager(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showRemoveModal && (
|
||||
<ConfirmRemoveModal
|
||||
projectName={project.name}
|
||||
onConfirm={async () => {
|
||||
setShowRemoveModal(false);
|
||||
await remove(project.id);
|
||||
}}
|
||||
onCancel={() => setShowRemoveModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeOperation && (
|
||||
<ContainerProgressModal
|
||||
projectName={project.name}
|
||||
@@ -869,3 +957,4 @@ function ActionButton({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
101
app/src/components/settings/MicrophoneSettings.tsx
Normal file
101
app/src/components/settings/MicrophoneSettings.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
|
||||
interface AudioDevice {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export default function MicrophoneSettings() {
|
||||
const { appSettings, saveSettings } = useSettings();
|
||||
const [devices, setDevices] = useState<AudioDevice[]>([]);
|
||||
const [selected, setSelected] = useState(appSettings?.default_microphone ?? "");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [permissionNeeded, setPermissionNeeded] = useState(false);
|
||||
|
||||
// Sync local state when appSettings change
|
||||
useEffect(() => {
|
||||
setSelected(appSettings?.default_microphone ?? "");
|
||||
}, [appSettings?.default_microphone]);
|
||||
|
||||
const enumerateDevices = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setPermissionNeeded(false);
|
||||
try {
|
||||
// Request mic permission first so device labels are available
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
|
||||
const allDevices = await navigator.mediaDevices.enumerateDevices();
|
||||
const mics = allDevices
|
||||
.filter((d) => d.kind === "audioinput")
|
||||
.map((d) => ({
|
||||
deviceId: d.deviceId,
|
||||
label: d.label || `Microphone (${d.deviceId.slice(0, 8)}...)`,
|
||||
}));
|
||||
setDevices(mics);
|
||||
} catch {
|
||||
setPermissionNeeded(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Enumerate devices on mount
|
||||
useEffect(() => {
|
||||
enumerateDevices();
|
||||
}, [enumerateDevices]);
|
||||
|
||||
const handleChange = async (deviceId: string) => {
|
||||
setSelected(deviceId);
|
||||
if (appSettings) {
|
||||
await saveSettings({ ...appSettings, default_microphone: deviceId || null });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Microphone</label>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||
Audio input device for Claude Code voice mode (/voice)
|
||||
</p>
|
||||
{permissionNeeded ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
Microphone permission required
|
||||
</span>
|
||||
<button
|
||||
onClick={enumerateDevices}
|
||||
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||
>
|
||||
Grant Access
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selected}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
disabled={loading}
|
||||
className="flex-1 px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
>
|
||||
<option value="">System Default</option>
|
||||
{devices.map((d) => (
|
||||
<option key={d.deviceId} value={d.deviceId}>
|
||||
{d.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={enumerateDevices}
|
||||
disabled={loading}
|
||||
title="Refresh microphone list"
|
||||
className="text-xs px-2 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? "..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,9 @@ export default function TerminalTabs() {
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate max-w-[120px]">{session.projectName}</span>
|
||||
<span className="truncate max-w-[120px]">
|
||||
{session.projectName}{session.sessionType === "bash" ? " (bash)" : ""}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -82,6 +82,25 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
// Send initial size
|
||||
resize(sessionId, term.cols, term.rows);
|
||||
|
||||
// Handle OSC 52 clipboard write sequences from programs inside the container.
|
||||
// When a program (e.g. Claude Code) copies text via xclip/xsel/pbcopy, the
|
||||
// container's shim emits an OSC 52 escape sequence which xterm.js routes here.
|
||||
const osc52Disposable = term.parser.registerOscHandler(52, (data) => {
|
||||
const idx = data.indexOf(";");
|
||||
if (idx === -1) return false;
|
||||
const payload = data.substring(idx + 1);
|
||||
if (payload === "?") return false; // clipboard read request, not supported
|
||||
try {
|
||||
const decoded = atob(payload);
|
||||
navigator.clipboard.writeText(decoded).catch((e) =>
|
||||
console.error("OSC 52 clipboard write failed:", e),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("OSC 52 decode failed:", e);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Handle user input -> backend
|
||||
const inputDisposable = term.onData((data) => {
|
||||
sendInput(sessionId, data);
|
||||
@@ -170,6 +189,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
aborted = true;
|
||||
detector.dispose();
|
||||
detectorRef.current = null;
|
||||
osc52Disposable.dispose();
|
||||
inputDisposable.dispose();
|
||||
scrollDisposable.dispose();
|
||||
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||||
|
||||
59
app/src/components/terminal/osc52.test.ts
Normal file
59
app/src/components/terminal/osc52.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* Tests the OSC 52 clipboard parsing logic used in TerminalView.
|
||||
* Extracted here to validate the decode/write path independently.
|
||||
*/
|
||||
|
||||
// Mirrors the handler registered in TerminalView.tsx
|
||||
function handleOsc52(data: string): string | null {
|
||||
const idx = data.indexOf(";");
|
||||
if (idx === -1) return null;
|
||||
const payload = data.substring(idx + 1);
|
||||
if (payload === "?") return null;
|
||||
try {
|
||||
return atob(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe("OSC 52 clipboard handler", () => {
|
||||
it("decodes a valid clipboard write sequence", () => {
|
||||
// "c;BASE64" where BASE64 encodes "https://example.com"
|
||||
const encoded = btoa("https://example.com");
|
||||
const result = handleOsc52(`c;${encoded}`);
|
||||
expect(result).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("decodes multi-line content", () => {
|
||||
const text = "line1\nline2\nline3";
|
||||
const encoded = btoa(text);
|
||||
const result = handleOsc52(`c;${encoded}`);
|
||||
expect(result).toBe(text);
|
||||
});
|
||||
|
||||
it("handles primary selection target (p)", () => {
|
||||
const encoded = btoa("selected text");
|
||||
const result = handleOsc52(`p;${encoded}`);
|
||||
expect(result).toBe("selected text");
|
||||
});
|
||||
|
||||
it("returns null for clipboard read request (?)", () => {
|
||||
expect(handleOsc52("c;?")).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for missing semicolon", () => {
|
||||
expect(handleOsc52("invalid")).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for invalid base64", () => {
|
||||
expect(handleOsc52("c;!!!not-base64!!!")).toBe(null);
|
||||
});
|
||||
|
||||
it("handles empty payload after selection target", () => {
|
||||
// btoa("") = ""
|
||||
const result = handleOsc52("c;");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
74
app/src/hooks/useFileManager.ts
Normal file
74
app/src/hooks/useFileManager.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { save, open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import type { FileEntry } from "../lib/types";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
|
||||
export function useFileManager(projectId: string) {
|
||||
const [currentPath, setCurrentPath] = useState("/workspace");
|
||||
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const navigate = useCallback(
|
||||
async (path: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await commands.listContainerFiles(projectId, path);
|
||||
setEntries(result);
|
||||
setCurrentPath(path);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const goUp = useCallback(() => {
|
||||
if (currentPath === "/") return;
|
||||
const parent = currentPath.replace(/\/[^/]+$/, "") || "/";
|
||||
navigate(parent);
|
||||
}, [currentPath, navigate]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
navigate(currentPath);
|
||||
}, [currentPath, navigate]);
|
||||
|
||||
const downloadFile = useCallback(
|
||||
async (entry: FileEntry) => {
|
||||
try {
|
||||
const hostPath = await save({ defaultPath: entry.name });
|
||||
if (!hostPath) return;
|
||||
await commands.downloadContainerFile(projectId, entry.path, hostPath);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const uploadFile = useCallback(async () => {
|
||||
try {
|
||||
const selected = await openDialog({ multiple: false, directory: false });
|
||||
if (!selected) return;
|
||||
await commands.uploadFileToContainer(projectId, selected as string, currentPath);
|
||||
await navigate(currentPath);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}, [projectId, currentPath, navigate]);
|
||||
|
||||
return {
|
||||
currentPath,
|
||||
entries,
|
||||
loading,
|
||||
error,
|
||||
navigate,
|
||||
goUp,
|
||||
refresh,
|
||||
downloadFile,
|
||||
uploadFile,
|
||||
};
|
||||
}
|
||||
@@ -17,10 +17,10 @@ export function useTerminal() {
|
||||
);
|
||||
|
||||
const open = useCallback(
|
||||
async (projectId: string, projectName: string) => {
|
||||
async (projectId: string, projectName: string, sessionType: "claude" | "bash" = "claude") => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
await commands.openTerminalSession(projectId, sessionId);
|
||||
addSession({ id: sessionId, projectId, projectName });
|
||||
await commands.openTerminalSession(projectId, sessionId, sessionType);
|
||||
addSession({ id: sessionId, projectId, projectName, sessionType });
|
||||
return sessionId;
|
||||
},
|
||||
[addSession],
|
||||
|
||||
103
app/src/hooks/useVoice.ts
Normal file
103
app/src/hooks/useVoice.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
|
||||
type VoiceState = "inactive" | "starting" | "active" | "error";
|
||||
|
||||
export function useVoice(sessionId: string, deviceId?: string | null) {
|
||||
const [state, setState] = useState<VoiceState>("inactive");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const workletRef = useRef<AudioWorkletNode | null>(null);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (state === "active" || state === "starting") return;
|
||||
setState("starting");
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 1. Start the audio bridge in the container (creates FIFO writer)
|
||||
await commands.startAudioBridge(sessionId);
|
||||
|
||||
// 2. Get microphone access (use specific device if configured)
|
||||
const audioConstraints: MediaTrackConstraints = {
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
};
|
||||
if (deviceId) {
|
||||
audioConstraints.deviceId = { exact: deviceId };
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: audioConstraints,
|
||||
});
|
||||
streamRef.current = stream;
|
||||
|
||||
// 3. Create AudioContext at 16kHz (browser handles resampling)
|
||||
const audioContext = new AudioContext({ sampleRate: 16000 });
|
||||
audioContextRef.current = audioContext;
|
||||
|
||||
// 4. Load AudioWorklet processor
|
||||
await audioContext.audioWorklet.addModule("/audio-capture-processor.js");
|
||||
|
||||
// 5. Connect: mic → worklet → (silent) destination
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const processor = new AudioWorkletNode(audioContext, "audio-capture-processor");
|
||||
workletRef.current = processor;
|
||||
|
||||
// 6. Handle PCM chunks from the worklet
|
||||
processor.port.onmessage = (event: MessageEvent<ArrayBuffer>) => {
|
||||
const bytes = Array.from(new Uint8Array(event.data));
|
||||
commands.sendAudioData(sessionId, bytes).catch(() => {
|
||||
// Audio bridge may have been closed — ignore send errors
|
||||
});
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(audioContext.destination);
|
||||
|
||||
setState("active");
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg);
|
||||
setState("error");
|
||||
// Clean up on failure
|
||||
await commands.stopAudioBridge(sessionId).catch(() => {});
|
||||
}
|
||||
}, [sessionId, state, deviceId]);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
// Tear down audio pipeline
|
||||
workletRef.current?.disconnect();
|
||||
workletRef.current = null;
|
||||
|
||||
if (audioContextRef.current) {
|
||||
await audioContextRef.current.close().catch(() => {});
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Stop the container-side audio bridge
|
||||
await commands.stopAudioBridge(sessionId).catch(() => {});
|
||||
|
||||
setState("inactive");
|
||||
setError(null);
|
||||
}, [sessionId]);
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
if (state === "active") {
|
||||
await stop();
|
||||
} else {
|
||||
await start();
|
||||
}
|
||||
}, [state, start, stop]);
|
||||
|
||||
return { state, error, start, stop, toggle };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, McpServer } from "./types";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, McpServer, FileEntry } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -39,8 +39,8 @@ export const detectHostTimezone = () =>
|
||||
invoke<string>("detect_host_timezone");
|
||||
|
||||
// Terminal
|
||||
export const openTerminalSession = (projectId: string, sessionId: string) =>
|
||||
invoke<void>("open_terminal_session", { projectId, sessionId });
|
||||
export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string) =>
|
||||
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType });
|
||||
export const terminalInput = (sessionId: string, data: number[]) =>
|
||||
invoke<void>("terminal_input", { sessionId, data });
|
||||
export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
||||
@@ -49,6 +49,12 @@ export const closeTerminalSession = (sessionId: string) =>
|
||||
invoke<void>("close_terminal_session", { sessionId });
|
||||
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
|
||||
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
|
||||
export const startAudioBridge = (sessionId: string) =>
|
||||
invoke<void>("start_audio_bridge", { sessionId });
|
||||
export const sendAudioData = (sessionId: string, data: number[]) =>
|
||||
invoke<void>("send_audio_data", { sessionId, data });
|
||||
export const stopAudioBridge = (sessionId: string) =>
|
||||
invoke<void>("stop_audio_bridge", { sessionId });
|
||||
|
||||
// MCP Servers
|
||||
export const listMcpServers = () => invoke<McpServer[]>("list_mcp_servers");
|
||||
@@ -59,6 +65,14 @@ export const updateMcpServer = (server: McpServer) =>
|
||||
export const removeMcpServer = (serverId: string) =>
|
||||
invoke<void>("remove_mcp_server", { serverId });
|
||||
|
||||
// Files
|
||||
export const listContainerFiles = (projectId: string, path: string) =>
|
||||
invoke<FileEntry[]>("list_container_files", { projectId, path });
|
||||
export const downloadContainerFile = (projectId: string, containerPath: string, hostPath: string) =>
|
||||
invoke<void>("download_container_file", { projectId, containerPath, hostPath });
|
||||
export const uploadFileToContainer = (projectId: string, hostPath: string, containerDir: string) =>
|
||||
invoke<void>("upload_file_to_container", { projectId, hostPath, containerDir });
|
||||
|
||||
// Updates
|
||||
export const getAppVersion = () => invoke<string>("get_app_version");
|
||||
export const checkForUpdates = () =>
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface Project {
|
||||
auth_mode: AuthMode;
|
||||
bedrock_config: BedrockConfig | null;
|
||||
allow_docker_access: boolean;
|
||||
mission_control_enabled: boolean;
|
||||
ssh_key_path: string | null;
|
||||
git_token: string | null;
|
||||
git_user_name: string | null;
|
||||
@@ -77,6 +78,7 @@ export interface TerminalSession {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
sessionType: "claude" | "bash";
|
||||
}
|
||||
|
||||
export type ImageSource = "registry" | "local_build" | "custom";
|
||||
@@ -100,6 +102,7 @@ export interface AppSettings {
|
||||
auto_check_updates: boolean;
|
||||
dismissed_update_version: string | null;
|
||||
timezone: string | null;
|
||||
default_microphone: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
@@ -133,3 +136,12 @@ export interface McpServer {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_directory: boolean;
|
||||
size: number;
|
||||
modified: string;
|
||||
permissions: string;
|
||||
}
|
||||
|
||||
@@ -101,6 +101,24 @@ WORKDIR /workspace
|
||||
|
||||
# ── Switch back to root for entrypoint (handles UID/GID remapping) ─────────
|
||||
USER root
|
||||
|
||||
# ── OSC 52 clipboard support ─────────────────────────────────────────────
|
||||
# Provides xclip/xsel/pbcopy shims that emit OSC 52 escape sequences,
|
||||
# allowing programs inside the container to copy to the host clipboard.
|
||||
COPY osc52-clipboard /usr/local/bin/osc52-clipboard
|
||||
RUN chmod +x /usr/local/bin/osc52-clipboard \
|
||||
&& ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/xclip \
|
||||
&& ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/xsel \
|
||||
&& ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/pbcopy
|
||||
|
||||
# ── Audio capture shim (voice mode) ────────────────────────────────────────
|
||||
# Provides fake rec/arecord that read PCM from a FIFO instead of a real mic,
|
||||
# allowing Claude Code voice mode to work inside the container.
|
||||
COPY audio-shim /usr/local/bin/audio-shim
|
||||
RUN chmod +x /usr/local/bin/audio-shim \
|
||||
&& ln -sf /usr/local/bin/audio-shim /usr/local/bin/rec \
|
||||
&& ln -sf /usr/local/bin/audio-shim /usr/local/bin/arecord
|
||||
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler
|
||||
|
||||
16
container/audio-shim
Normal file
16
container/audio-shim
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
# Audio capture shim for Triple-C voice mode.
|
||||
# Claude Code spawns `rec` or `arecord` to capture mic audio.
|
||||
# Inside Docker there is no mic, so this shim reads PCM data from a
|
||||
# FIFO that the Tauri host app writes to, and outputs it on stdout.
|
||||
|
||||
FIFO=/tmp/triple-c-audio-input
|
||||
|
||||
# Create the FIFO if it doesn't already exist
|
||||
[ -p "$FIFO" ] || mkfifo "$FIFO" 2>/dev/null
|
||||
|
||||
# Clean exit on SIGTERM (Claude Code sends this when recording stops)
|
||||
trap 'exit 0' TERM INT
|
||||
|
||||
# Stream PCM from the FIFO to stdout until we get a signal or EOF
|
||||
cat "$FIFO"
|
||||
@@ -116,6 +116,24 @@ if [ -n "$CLAUDE_INSTRUCTIONS" ]; then
|
||||
unset CLAUDE_INSTRUCTIONS
|
||||
fi
|
||||
|
||||
# ── Mission Control setup ───────────────────────────────────────────────────
|
||||
if [ "$MISSION_CONTROL_ENABLED" = "1" ]; then
|
||||
MC_HOME="/home/claude/mission-control"
|
||||
MC_LINK="/workspace/mission-control"
|
||||
if [ ! -d "$MC_HOME/.git" ]; then
|
||||
echo "entrypoint: cloning mission-control..."
|
||||
su -s /bin/bash claude -c \
|
||||
'git clone https://github.com/msieurthenardier/mission-control.git /home/claude/mission-control' \
|
||||
|| echo "entrypoint: warning — failed to clone mission-control"
|
||||
else
|
||||
echo "entrypoint: mission-control already present, skipping clone"
|
||||
fi
|
||||
# Symlink into workspace so Claude sees it at /workspace/mission-control
|
||||
ln -sfn "$MC_HOME" "$MC_LINK"
|
||||
chown -h claude:claude "$MC_LINK"
|
||||
unset MISSION_CONTROL_ENABLED
|
||||
fi
|
||||
|
||||
# ── MCP server configuration ────────────────────────────────────────────────
|
||||
# Merge MCP server config into ~/.claude.json (preserves existing keys like
|
||||
# OAuth tokens). Creates the file if it doesn't exist.
|
||||
|
||||
26
container/osc52-clipboard
Normal file
26
container/osc52-clipboard
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
# OSC 52 clipboard provider — sends clipboard data to the host system clipboard
|
||||
# via OSC 52 terminal escape sequences. Installed as xclip/xsel/pbcopy so that
|
||||
# programs inside the container (e.g. Claude Code) can copy to clipboard.
|
||||
#
|
||||
# Supports common invocations:
|
||||
# echo "text" | xclip -selection clipboard
|
||||
# echo "text" | xsel --clipboard --input
|
||||
# echo "text" | pbcopy
|
||||
#
|
||||
# Paste/output requests exit silently (not supported via OSC 52).
|
||||
|
||||
# Detect paste/output mode — exit silently since we can't read the host clipboard
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-o|--output) exit 0 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Read all input from stdin
|
||||
data=$(cat)
|
||||
[ -z "$data" ] && exit 0
|
||||
|
||||
# Base64 encode and write OSC 52 escape sequence to the controlling terminal
|
||||
encoded=$(printf '%s' "$data" | base64 | tr -d '\n')
|
||||
printf '\033]52;c;%s\a' "$encoded" > /dev/tty 2>/dev/null
|
||||
Reference in New Issue
Block a user