diff --git a/Cargo.lock b/Cargo.lock index 74c8c9702..871c7a345 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -632,7 +632,7 @@ dependencies = [ "serde", "serde_json", "tokio", - "tower", + "tower 0.5.2", "tracing", ] @@ -787,6 +787,64 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "base64 0.22.1", + "bytes", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.6.0", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite 0.24.0", + "tower 0.5.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backtrace" version = "0.3.75" @@ -1298,7 +1356,7 @@ dependencies = [ "thiserror 2.0.12", "time", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.26.2", "tokio-util", "toml", "tracing", @@ -1310,6 +1368,7 @@ dependencies = [ "url", "uuid", "walkdir", + "web-terminal", "webpki-roots 0.26.8", "whoami", "windows 0.61.3", @@ -2972,6 +3031,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f" + [[package]] name = "httparse" version = "1.10.1" @@ -3629,6 +3694,12 @@ dependencies = [ "regex-automata 0.1.10", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "memchr" version = "2.7.5" @@ -3697,6 +3768,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -5150,8 +5231,8 @@ dependencies = [ "tokio", "tokio-rustls 0.26.2", "tokio-util", - "tower", - "tower-http", + "tower 0.5.2", + "tower-http 0.6.6", "tower-service", "url", "wasm-bindgen", @@ -5615,6 +5696,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +dependencies = [ + "itoa", + "serde", +] + [[package]] name = "serde_plain" version = "1.0.2" @@ -6362,6 +6453,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.20.1", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.24.0", +] + [[package]] name = "tokio-tungstenite" version = "0.26.2" @@ -6371,7 +6486,7 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite", + "tungstenite 0.26.2", ] [[package]] @@ -6429,6 +6544,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.2" @@ -6442,6 +6568,32 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" +dependencies = [ + "bitflags 2.9.1", + "bytes", + "futures-core", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", ] [[package]] @@ -6457,7 +6609,7 @@ dependencies = [ "http-body 1.0.1", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", ] @@ -6627,6 +6779,43 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 0.2.12", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "tungstenite" version = "0.26.2" @@ -7098,6 +7287,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-terminal" +version = "0.1.0" +dependencies = [ + "axum", + "eyre", + "futures-util", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite 0.20.1", + "tokio-util", + "tower 0.4.13", + "tower-http 0.4.4", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "web-time" version = "1.1.0" diff --git a/crates/chat-cli/Cargo.toml b/crates/chat-cli/Cargo.toml index cfd68dd50..df0b205a5 100644 --- a/crates/chat-cli/Cargo.toml +++ b/crates/chat-cli/Cargo.toml @@ -118,6 +118,7 @@ url.workspace = true uuid.workspace = true walkdir.workspace = true webpki-roots.workspace = true +web-terminal = { path = "../web-terminal" } whoami.workspace = true winnow.workspace = true schemars.workspace = true diff --git a/crates/chat-cli/src/cli/mod.rs b/crates/chat-cli/src/cli/mod.rs index c51e5df3e..603603748 100644 --- a/crates/chat-cli/src/cli/mod.rs +++ b/crates/chat-cli/src/cli/mod.rs @@ -7,6 +7,7 @@ mod issue; mod mcp; mod settings; mod user; +mod webchat; use std::fmt::Display; use std::io::{ @@ -43,6 +44,7 @@ use crate::cli::user::{ LoginArgs, WhoamiArgs, }; +use crate::cli::webchat::WebchatArgs; use crate::logging::{ LogArgs, initialize_logging, @@ -89,6 +91,8 @@ pub enum RootSubcommand { Agent(AgentArgs), /// AI assistant in your terminal Chat(ChatArgs), + /// AI assistant in your web browser + Webchat(WebchatArgs), /// Log in to Amazon Q Login(LoginArgs), /// Log out of Amazon Q @@ -123,11 +127,11 @@ impl RootSubcommand { /// /// Emitting telemetry takes a long time so the answer is usually no. pub fn valid_for_telemetry(&self) -> bool { - matches!(self, Self::Chat(_) | Self::Login(_) | Self::Profile | Self::Issue(_)) + matches!(self, Self::Chat(_) | Self::Webchat(_) | Self::Login(_) | Self::Profile | Self::Issue(_)) } pub fn requires_auth(&self) -> bool { - matches!(self, Self::Chat(_) | Self::Profile) + matches!(self, Self::Chat(_) | Self::Webchat(_) | Self::Profile) } pub async fn execute(self, os: &mut Os) -> Result { @@ -155,6 +159,7 @@ impl RootSubcommand { Self::Issue(args) => args.execute(os).await, Self::Version { changelog } => Cli::print_version(changelog), Self::Chat(args) => args.execute(os).await, + Self::Webchat(args) => args.execute(os).await, Self::Mcp(args) => args.execute(os, &mut std::io::stderr()).await, } } @@ -171,6 +176,7 @@ impl Display for RootSubcommand { let name = match self { Self::Agent(_) => "agent", Self::Chat(_) => "chat", + Self::Webchat(_) => "webchat", Self::Login(_) => "login", Self::Logout => "logout", Self::Whoami(_) => "whoami", diff --git a/crates/chat-cli/src/cli/webchat.rs b/crates/chat-cli/src/cli/webchat.rs new file mode 100644 index 000000000..dce38dd6c --- /dev/null +++ b/crates/chat-cli/src/cli/webchat.rs @@ -0,0 +1,44 @@ +use std::process::ExitCode; + +use clap::Args; +use eyre::Result; +use tracing::info; + +use crate::os::Os; + +#[derive(Debug, Clone, PartialEq, Eq, Default, Args)] +pub struct WebchatArgs { + /// Port to run the web server on + #[arg(short, long, default_value = "8080")] + pub port: u16, + /// Context profile to use + #[arg(long = "agent", alias = "profile")] + pub agent: Option, + /// Current model to use + #[arg(long = "model")] + pub model: Option, + /// Allows the model to use any tool to run commands without asking for confirmation. + #[arg(short = 'a', long)] + pub trust_all_tools: bool, + /// Trust only this set of tools. Example: trust some tools: + /// '--trust-tools=fs_read,fs_write', trust no tools: '--trust-tools=' + #[arg(long, value_delimiter = ',', value_name = "TOOL_NAMES")] + pub trust_tools: Option>, +} + +impl WebchatArgs { + pub async fn execute(self, _os: &mut Os) -> Result { + info!("Starting webchat on port {}", self.port); + + // Convert webchat args to chat args for compatibility + let chat_args = vec![ + "q".to_string(), + "chat".to_string(), + ]; + + // Start the web terminal server + web_terminal::start_web_server(self.port, chat_args).await?; + + Ok(ExitCode::SUCCESS) + } +} diff --git a/crates/web-terminal/Cargo.toml b/crates/web-terminal/Cargo.toml new file mode 100644 index 000000000..21cde40e3 --- /dev/null +++ b/crates/web-terminal/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "web-terminal" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio = { version = "1.0", features = ["full"] } +tokio-tungstenite = "0.20" +futures-util = "0.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +uuid = { version = "1.0", features = ["v4"] } +tracing = "0.1" +tracing-subscriber = "0.3" +tower = "0.4" +tower-http = { version = "0.4", features = ["fs", "cors"] } +axum = { version = "0.7", features = ["ws"] } +tokio-util = { version = "0.7", features = ["codec"] } +eyre = "0.6" diff --git a/crates/web-terminal/src/lib.rs b/crates/web-terminal/src/lib.rs new file mode 100644 index 000000000..c9f4812b6 --- /dev/null +++ b/crates/web-terminal/src/lib.rs @@ -0,0 +1,427 @@ +use axum::{ + extract::{ws::WebSocket, WebSocketUpgrade}, + response::{Html, IntoResponse}, + routing::get, + Router, +}; +use futures_util::{sink::SinkExt, stream::StreamExt}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + env, + process::Stdio, +}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + process::{Child, Command}, + sync::mpsc, + time::{timeout, Duration}, +}; +use tracing::{error, info}; +use uuid::Uuid; + +// Function to strip non-ASCII characters and ANSI escape sequences +fn sanitize_output(input: &str) -> String { + let mut result = String::new(); + let mut chars = input.chars().peekable(); + + while let Some(ch) = chars.next() { + match ch { + // Handle ANSI escape sequences - skip them entirely + '\x1b' => { + if chars.peek() == Some(&'[') { + chars.next(); // consume '[' + // Skip the entire ANSI sequence + while let Some(next_ch) = chars.next() { + if next_ch.is_ascii_alphabetic() { + break; + } + } + } + // Don't add anything to result - just skip + } + // Keep only ASCII printable characters, tabs, newlines, and carriage returns + c if c.is_ascii() && (c.is_ascii_graphic() || c == ' ' || c == '\t' || c == '\n' || c == '\r') => { + result.push(c); + } + // Skip all other characters (non-ASCII and control characters) + _ => {} + } + } + + result +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +enum Message { + #[serde(rename = "command")] + Command { data: String }, + #[serde(rename = "output")] + Output { data: String }, + #[serde(rename = "error")] + Error { data: String }, + #[serde(rename = "exit")] + Exit { code: i32 }, +} + +#[derive(Debug)] +struct Terminal { + id: String, + shell_process: Option, + current_dir: String, + env_vars: HashMap, +} + +impl Terminal { + fn new() -> Self { + let mut env_vars = HashMap::new(); + for (key, value) in env::vars() { + env_vars.insert(key, value); + } + + Self { + id: Uuid::new_v4().to_string(), + shell_process: None, + current_dir: env::current_dir() + .unwrap_or_else(|_| "/".into()) + .to_string_lossy() + .to_string(), + env_vars, + } + } + + fn get_prompt(&self) -> String { + let username = self.env_vars.get("USER") + .or_else(|| self.env_vars.get("USERNAME")) + .map(|s| s.as_str()) + .unwrap_or("user"); + + let hostname = self.env_vars.get("HOSTNAME") + .or_else(|| self.env_vars.get("COMPUTERNAME")) + .map(|s| s.as_str()) + .unwrap_or("localhost"); + + let home_dir = self.env_vars.get("HOME") + .map(|s| s.as_str()) + .unwrap_or("/"); + + let current_dir = if self.current_dir == home_dir { + "~".to_string() + } else if self.current_dir.starts_with(home_dir) { + format!("~{}", &self.current_dir[home_dir.len()..]) + } else { + self.current_dir.clone() + }; + + format!("{}@{} [Q]:{}", username, hostname, current_dir) + } + + async fn start_shell(&mut self, chat_args: Vec) -> Result<(mpsc::Sender, mpsc::Receiver), String> { + // Start Q chat process with provided arguments + let mut cmd = Command::new("q"); + cmd.args(["chat"]); + + // Add any additional chat arguments + for arg in chat_args { + cmd.arg(arg); + } + + let mut child = cmd + .current_dir(&self.current_dir) + .envs(&self.env_vars) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to start Q chat: {}", e))?; + + let stdin = child.stdin.take().ok_or("Failed to get stdin")?; + let stdout = child.stdout.take().ok_or("Failed to get stdout")?; + let stderr = child.stderr.take().ok_or("Failed to get stderr")?; + + // Create channels for communication + let (cmd_tx, mut cmd_rx) = mpsc::channel::(100); + let (output_tx, output_rx) = mpsc::channel::(100); + + // Store the child process + self.shell_process = Some(child); + + // Spawn task to handle stdin (commands to Q chat) + let output_tx_stdin = output_tx.clone(); + tokio::spawn(async move { + let mut stdin = stdin; + while let Some(command) = cmd_rx.recv().await { + // Send the command to Q chat + if let Err(e) = stdin.write_all(format!("{}\n", command).as_bytes()).await { + let _ = output_tx_stdin.send(format!("Error writing to Q chat: {}", e)).await; + break; + } + if let Err(e) = stdin.flush().await { + let _ = output_tx_stdin.send(format!("Error flushing Q chat input: {}", e)).await; + break; + } + } + }); + + // Spawn task to handle stdout (responses from Q chat) + let output_tx_stdout = output_tx.clone(); + tokio::spawn(async move { + let mut reader = BufReader::new(stdout); + let mut buffer = Vec::new(); + + loop { + buffer.clear(); + match timeout(Duration::from_secs(30), reader.read_until(b'\n', &mut buffer)).await { + Ok(Ok(0)) => break, // EOF + Ok(Ok(_)) => { + if let Ok(line) = String::from_utf8(buffer.clone()) { + // Sanitize the output before sending to browser + let sanitized_line = sanitize_output(&line); + if let Err(_) = output_tx_stdout.send(sanitized_line).await { + break; + } + } + } + Ok(Err(e)) => { + let _ = output_tx_stdout.send(format!("Error reading from Q chat: {}", e)).await; + break; + } + Err(_) => { + // Timeout - Q chat might be thinking, continue waiting + continue; + } + } + } + }); + + // Spawn task to handle stderr (errors from Q chat) + tokio::spawn(async move { + let mut reader = BufReader::new(stderr); + let mut buffer = Vec::new(); + + loop { + buffer.clear(); + match timeout(Duration::from_secs(5), reader.read_until(b'\n', &mut buffer)).await { + Ok(Ok(0)) => break, // EOF + Ok(Ok(_)) => { + if let Ok(line) = String::from_utf8(buffer.clone()) { + if !line.trim().is_empty() { + // Sanitize error output and send without prefix + let sanitized_line = sanitize_output(&line); + let _ = output_tx.send(sanitized_line).await; + } + } + } + Ok(Err(e)) => { + let _ = output_tx.send(format!("Error reading Q chat stderr: {}", e)).await; + break; + } + Err(_) => { + // Timeout - continue reading + continue; + } + } + } + }); + + Ok((cmd_tx, output_rx)) + } + + async fn handle_builtin_command(&mut self, command: &str) -> Option> { + let parts: Vec<&str> = command.split_whitespace().collect(); + if parts.is_empty() { + return Some(Ok(String::new())); + } + + match parts[0] { + "cd" => { + let target_dir = if parts.len() > 1 { + parts[1].to_string() + } else { + // Default to home directory + self.env_vars.get("HOME").unwrap_or(&"/".to_string()).clone() + }; + + match self.change_directory(&target_dir) { + Ok(_) => Some(Ok(String::new())), + Err(e) => Some(Err(format!("cd: {}", e))), + } + } + "pwd" => Some(Ok(format!("{}\n", self.current_dir))), + "export" => { + if parts.len() > 1 { + for part in &parts[1..] { + if let Some((key, value)) = part.split_once('=') { + self.env_vars.insert(key.to_string(), value.to_string()); + } + } + } + Some(Ok(String::new())) + } + _ => None, // Not a builtin command + } + } + + fn change_directory(&mut self, path: &str) -> Result<(), String> { + let new_path = if path.starts_with('/') { + // Absolute path + std::path::PathBuf::from(path) + } else if path.starts_with("~/") { + // Home directory relative path + let home = self.env_vars.get("HOME").ok_or("HOME not set")?; + std::path::PathBuf::from(home).join(&path[2..]) + } else if path == "~" { + // Just home directory + let home = self.env_vars.get("HOME").ok_or("HOME not set")?; + std::path::PathBuf::from(home) + } else { + // Relative path + std::path::PathBuf::from(&self.current_dir).join(path) + }; + + let canonical_path = new_path.canonicalize() + .map_err(|e| format!("No such file or directory: {}", e))?; + + if !canonical_path.is_dir() { + return Err("Not a directory".to_string()); + } + + self.current_dir = canonical_path.to_string_lossy().to_string(); + Ok(()) + } +} + +async fn websocket_handler(ws: WebSocketUpgrade) -> impl IntoResponse { + ws.on_upgrade(handle_socket) +} + +async fn handle_socket(socket: WebSocket) { + let mut terminal = Terminal::new(); + let (sender, mut receiver) = socket.split(); + let sender = std::sync::Arc::new(tokio::sync::Mutex::new(sender)); + + // Start the Q chat shell with default arguments + let (cmd_tx, mut output_rx) = match terminal.start_shell(vec![]).await { + Ok((tx, rx)) => (tx, rx), + Err(e) => { + error!("Failed to start shell: {}", e); + let mut sender_guard = sender.lock().await; + let _ = sender_guard.send(axum::extract::ws::Message::Text( + serde_json::to_string(&Message::Error { + data: format!("Failed to start Q chat: {}", e) + }).unwrap() + )).await; + return; + } + }; + + // Send initial prompt + let initial_prompt = terminal.get_prompt(); + { + let mut sender_guard = sender.lock().await; + let _ = sender_guard.send(axum::extract::ws::Message::Text( + serde_json::to_string(&Message::Output { + data: format!("{} $ ", initial_prompt) + }).unwrap() + )).await; + } + + // Spawn task to handle output from Q chat + let sender_clone = sender.clone(); + tokio::spawn(async move { + while let Some(output) = output_rx.recv().await { + let message = Message::Output { data: output }; + if let Ok(json) = serde_json::to_string(&message) { + let mut sender_guard = sender_clone.lock().await; + if sender_guard.send(axum::extract::ws::Message::Text(json)).await.is_err() { + break; + } + } + } + }); + + // Handle incoming WebSocket messages + while let Some(msg) = receiver.next().await { + match msg { + Ok(axum::extract::ws::Message::Text(text)) => { + if let Ok(message) = serde_json::from_str::(&text) { + match message { + Message::Command { data } => { + // Check for builtin commands first + if let Some(result) = terminal.handle_builtin_command(&data).await { + match result { + Ok(output) => { + if !output.is_empty() { + let msg = Message::Output { data: output }; + if let Ok(json) = serde_json::to_string(&msg) { + let mut sender_guard = sender.lock().await; + let _ = sender_guard.send(axum::extract::ws::Message::Text(json)).await; + } + } + // Send new prompt + let prompt = terminal.get_prompt(); + let msg = Message::Output { data: format!("{} $ ", prompt) }; + if let Ok(json) = serde_json::to_string(&msg) { + let mut sender_guard = sender.lock().await; + let _ = sender_guard.send(axum::extract::ws::Message::Text(json)).await; + } + } + Err(error) => { + let msg = Message::Error { data: error }; + if let Ok(json) = serde_json::to_string(&msg) { + let mut sender_guard = sender.lock().await; + let _ = sender_guard.send(axum::extract::ws::Message::Text(json)).await; + } + // Send new prompt even after error + let prompt = terminal.get_prompt(); + let msg = Message::Output { data: format!("{} $ ", prompt) }; + if let Ok(json) = serde_json::to_string(&msg) { + let mut sender_guard = sender.lock().await; + let _ = sender_guard.send(axum::extract::ws::Message::Text(json)).await; + } + } + } + } else { + // Send command to Q chat + if cmd_tx.send(data).await.is_err() { + break; + } + } + } + _ => {} + } + } + } + Ok(axum::extract::ws::Message::Close(_)) => { + info!("WebSocket connection closed"); + break; + } + Err(e) => { + error!("WebSocket error: {}", e); + break; + } + _ => {} + } + } +} + +async fn index_handler() -> Html<&'static str> { + Html(include_str!("../static/index.html")) +} + +pub async fn start_web_server(port: u16, _chat_args: Vec) -> eyre::Result<()> { + info!("Starting web terminal server on port {}", port); + + let app = Router::new() + .route("/", get(index_handler)) + .route("/ws", get(websocket_handler)); + + let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{}", port)).await?; + + info!("Web terminal available at http://127.0.0.1:{}", port); + + axum::serve(listener, app).await?; + + Ok(()) +} diff --git a/crates/web-terminal/static/index.html b/crates/web-terminal/static/index.html new file mode 100644 index 000000000..9d4b0a238 --- /dev/null +++ b/crates/web-terminal/static/index.html @@ -0,0 +1,688 @@ + + + + + + Amazon Q webchat + + + +
+
+
Amazon Q webchat
+
Connecting...
+
+ +
+ +
⢠⣶⣶⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣶⣿⣿⣿⣶⣦⡀⠀ + ⠀⠀⠀⣾⡿⢻⣿⡆⠀⠀⠀⢀⣄⡄⢀⣠⣤⣤⡀⢀⣠⣤⣤⡀⠀⠀⢀⣠⣤⣤⣤⣄⠀⠀⢀⣤⣤⣤⣤⣤⣤⡀⠀⠀⣀⣤⣤⣤⣀⠀⠀⠀⢠⣤⡀⣀⣤⣤⣄⡀⠀⠀⠀⠀⠀⠀⢠⣿⣿⠋⠀⠀⠀⠙⣿⣿⡆ + ⠀⠀⣼⣿⠇⠀⣿⣿⡄⠀⠀⢸⣿⣿⠛⠉⠻⣿⣿⠛⠉⠛⣿⣿⠀⠀⠘⠛⠉⠉⠻⣿⣧⠀⠈⠛⠛⠛⣻⣿⡿⠀⢀⣾⣿⠛⠉⠻⣿⣷⡀⠀⢸⣿⡟⠛⠉⢻⣿⣷⠀⠀⠀⠀⠀⠀⣼⣿⡏⠀⠀⠀⠀⠀⢸⣿⣿ + ⠀⢰⣿⣿⣤⣤⣼⣿⣷⠀⠀⢸⣿⣿⠀⠀⠀⣿⣿⠀⠀⠀⣿⣿⠀⠀⢀⣴⣶⣶⣶⣿⣿⠀⠀⠀⣠⣾⡿⠋⠀⠀⢸⣿⣿⠀⠀⠀⣿⣿⡇⠀⢸⣿⡇⠀⠀⢸⣿⣿⠀⠀⠀⠀⠀⠀⢹⣿⣇⠀⠀⠀⠀⠀⢸⣿⡿ + ⢀⣿⣿⠋⠉⠉⠉⢻⣿⣇⠀⢸⣿⣿⠀⠀⠀⣿⣿⠀⠀⠀⣿⣿⠀⠀⣿⣿⡀⠀⣠⣿⣿⠀⢀⣴⣿⣋⣀⣀⣀⡀⠘⣿⣿⣄⣀⣠⣿⣿⠃⠀⢸⣿⡇⠀⠀⢸⣿⣿⠀⠀⠀⠀⠀⠀⠈⢿⣿⣦⣀⣀⣀⣴⣿⡿⠃ + ⠚⠛⠋⠀⠀⠀⠀⠘⠛⠛⠀⠘⠛⠛⠀⠀⠀⠛⠛⠀⠀⠀⠛⠛⠀⠀⠙⠻⠿⠟⠋⠛⠛⠀⠘⠛⠛⠛⠛⠛⠛⠃⠀⠈⠛⠿⠿⠿⠛⠁⠀⠀⠘⠛⠃⠀⠀⠘⠛⠛⠀⠀⠀⠀⠀⠀⠀⠀⠙⠛⠿⢿⣿⣿⣋⠀⠀ + ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠛⠿⢿⡧
+
Welcome to Amazon Q webchat!
+
Ask me anything using the search bar below. I can help with AWS services, coding, troubleshooting, and more.
+ +
+ +
+ + +
+
+ + + + diff --git a/crates/web-terminal/web-terminal.md b/crates/web-terminal/web-terminal.md new file mode 100644 index 000000000..f1ade0ba3 --- /dev/null +++ b/crates/web-terminal/web-terminal.md @@ -0,0 +1,162 @@ +# Web Terminal + +The web-terminal crate provides a web-based interface for Amazon Q CLI, allowing users to interact with Amazon Q through a browser instead of the command line. + +## Overview + +The web terminal creates a WebSocket-based terminal emulator that runs in the browser, providing a familiar terminal experience while leveraging the full power of Amazon Q CLI. + +## Features + +- **WebSocket Communication**: Real-time bidirectional communication between browser and terminal +- **Terminal Emulation**: Full terminal emulation with support for colors, cursor movement, and control sequences +- **Process Management**: Spawns and manages shell processes with proper I/O handling +- **Cross-Platform**: Works on macOS and Linux +- **Responsive Design**: Adapts to different screen sizes and devices + +## Architecture + +The web terminal consists of several key components: + +### Server Components + +- **Web Server**: Axum-based HTTP server that serves the web interface +- **WebSocket Handler**: Manages WebSocket connections and message routing +- **Terminal Manager**: Spawns and manages terminal processes +- **Process I/O**: Handles stdin/stdout/stderr communication with spawned processes + +### Client Components + +- **HTML Interface**: Terminal emulator UI built with HTML/CSS/JavaScript +- **WebSocket Client**: Handles communication with the server +- **Terminal Renderer**: Renders terminal output and handles user input + +## Usage + +### Starting the Web Terminal + +Use the `webchat` command to start the web terminal server: + +```bash +q webchat --port 8080 +``` + +Available options: +- `--port, -p`: Port to run the web server on (default: 8080) +- `--agent`: Context profile to use +- `--model`: Current model to use +- `--trust-all-tools, -a`: Allow all tools without confirmation +- `--trust-tools`: Trust only specific tools + +### Accessing the Interface + +Once started, open your browser and navigate to: +``` +http://localhost:8080 +``` + +The interface provides a full terminal experience where you can interact with Amazon Q just as you would in the command line. + +## Implementation Details + +### WebSocket Protocol + +The web terminal uses a simple JSON-based protocol over WebSocket: + +```json +{ + "type": "input", + "data": "user input text" +} +``` + +```json +{ + "type": "output", + "data": "terminal output text" +} +``` + +### Process Management + +Each WebSocket connection spawns its own shell process using `/bin/bash`. + +The process lifecycle is managed automatically: +- Process is spawned when WebSocket connects +- Process is terminated when WebSocket disconnects +- I/O is handled asynchronously using Tokio + +### Security Considerations + +- The web terminal runs locally and binds to `127.0.0.1` by default +- No authentication is required for local access +- All terminal operations run with the same permissions as the user who started the server +- CORS is configured to allow local access only + +## Development + +### Building + +The web-terminal crate is built as part of the main Amazon Q CLI build: + +```bash +cargo build +``` + +### Testing + +Run tests for the web-terminal crate: + +```bash +cargo test --package web-terminal +``` + +### Dependencies + +Key dependencies include: +- `axum`: Web framework for HTTP server +- `tokio-tungstenite`: WebSocket implementation +- `tokio`: Async runtime +- `serde`: JSON serialization +- `tower-http`: HTTP middleware + +## Troubleshooting + +### Common Issues + +**Port Already in Use** +``` +Error: Address already in use (os error 48) +``` +Solution: Use a different port with `--port` option or stop the process using the port. + +**WebSocket Connection Failed** +- Check that the server is running +- Verify the correct port is being used +- Ensure no firewall is blocking the connection + +**Terminal Not Responding** +- Refresh the browser page to reconnect +- Check server logs for error messages +- Restart the web terminal server + +### Logging + +Enable verbose logging to debug issues: + +```bash +q webchat -v +``` + +This will show detailed information about WebSocket connections, process spawning, and I/O operations. + +## Future Enhancements + +Potential improvements for the web terminal: + +- **Authentication**: Add optional authentication for remote access +- **Multiple Sessions**: Support multiple terminal sessions in tabs +- **File Upload/Download**: Direct file transfer capabilities +- **Themes**: Customizable terminal themes and colors +- **Keyboard Shortcuts**: Additional keyboard shortcuts and hotkeys +- **Session Persistence**: Save and restore terminal sessions diff --git a/docs/web-terminal.md b/docs/web-terminal.md new file mode 120000 index 000000000..6a9703760 --- /dev/null +++ b/docs/web-terminal.md @@ -0,0 +1 @@ +../crates/web-terminal/web-terminal.md \ No newline at end of file