From 6b35b5b7e6e70b726a401274809fb489b9f16000 Mon Sep 17 00:00:00 2001 From: Misaka Date: Wed, 21 Oct 2026 19:49:59 +0800 Subject: [PATCH 1/5] fix(detect): recognize Cursor launched as agent (#515) --- src/agent/cursor/mod.rs | 72 ++++++++ src/detect.rs | 164 +++++++++++++++++- .../docs/docs/extend/adding-agent-support.mdx | 5 + .../src/content/docs/docs/guides/agents.mdx | 4 + .../content/docs/docs/reference/agents.mdx | 6 + 5 files changed, 248 insertions(+), 3 deletions(-) diff --git a/src/agent/cursor/mod.rs b/src/agent/cursor/mod.rs index 6a6575ee6..4f212ca80 100644 --- a/src/agent/cursor/mod.rs +++ b/src/agent/cursor/mod.rs @@ -38,3 +38,75 @@ pub(super) const DESCRIPTOR: AgentDescriptor = AgentDescriptor { }), integration: None, }; + +/// Index of the install script in a lowercased argv, when this argv is Cursor. +pub(crate) type ArgvScript = fn(&[String]) -> Option; + +/// argv predicate for Cursor's `agent` wrapper. `None` unless `id` is Cursor. +/// +/// The wrapper execs Node with argv0 rewritten to `agent`, so the process is +/// not named `cursor-agent`. The install script path is the evidence. +pub(crate) fn argv_script(id: &str) -> Option { + (id == "cursor").then_some(script_slot) +} + +/// Index of `…/cursor-agent/versions//index.js` in a lowercased argv. +/// `argv0` must be the rewritten launcher or Node. Later arguments are ignored. +fn script_slot(argv: &[String]) -> Option { + let (first, rest) = argv.split_first()?; + let argv0 = base_name(first); + if !matches!(argv0, "agent" | "agent.cmd" | "node" | "nodejs") { + return None; + } + let index = positional(rest)?; + is_install_script(&rest[index]).then_some(index + 1) +} + +fn base_name(token: &str) -> &str { + token + .rsplit(['/', '\\']) + .next() + .unwrap_or(token) + .trim_start_matches('-') + .trim_end_matches(".exe") +} + +/// First argument that is not a flag. `-r`, `--require`, and `--loader` also +/// consume their value, matching the interpreter script slot used elsewhere. +fn positional(args: &[String]) -> Option { + let mut index = 0; + while index < args.len() { + let arg = args[index].as_str(); + if arg.starts_with('-') { + if matches!(arg, "-r" | "--require" | "--loader") { + index += 2; + } else { + index += 1; + } + continue; + } + return Some(index); + } + None +} + +/// Cursor's installer runs `…/cursor-agent/versions//index.js`. +/// The sequence has to be the end of the path, so an earlier directory named +/// `cursor-agent` does not hide the install, and a project of that name is +/// not claimed. +fn is_install_script(token: &str) -> bool { + let mut tail = [""; 4]; + let mut count = 0usize; + for part in token.split(['/', '\\']).filter(|part| !part.is_empty()) { + tail[0] = tail[1]; + tail[1] = tail[2]; + tail[2] = tail[3]; + tail[3] = part; + count += 1; + } + count >= 4 + && tail[0] == "cursor-agent" + && tail[1] == "versions" + && !tail[2].starts_with('.') + && matches!(tail[3], "index.js" | "index.mjs" | "index.cjs") +} diff --git a/src/detect.rs b/src/detect.rs index 5b903d906..dd22c4e41 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -40,6 +40,10 @@ struct AgentIdent { distinct: Vec, ambiguous: Vec, binary_matcher: Option bool>, + /// Adapter check for a wrapper that rewrites argv0. Returns the script + /// token's index in the lowercased argv. Cleared when a manifest replaces + /// the agent's identity. + argv_script: Option, interpreter_packages: Vec, overlap_priority: u8, } @@ -70,6 +74,7 @@ fn builtin_agents() -> Vec { .map(|value| value.to_string()) .collect(), binary_matcher: agent.identity.binary_matcher, + argv_script: crate::agent::cursor::argv_script(agent.id), interpreter_packages: agent .identity .interpreter_packages @@ -1022,6 +1027,7 @@ impl ManifestFile { distinct: Vec::new(), ambiguous: Vec::new(), binary_matcher: None, + argv_script: None, interpreter_packages: Vec::new(), overlap_priority: 0, }); @@ -1032,6 +1038,7 @@ impl ManifestFile { entry.distinct.clear(); entry.ambiguous.clear(); entry.binary_matcher = None; + entry.argv_script = None; entry.interpreter_packages.clear(); } entry.distinct.extend(lc(&id.distinct)); @@ -1470,10 +1477,13 @@ impl Manifests { // is `node` / `python` and the real name is the script slot. Nothing // later counts -- `cargo test --example amp` must not resolve to amp. if is_interpreter(first) { - let i = interpreter_script_slot(rest)?; - return self.match_interpreter_script(&rest[i]); + if let Some(i) = interpreter_script_slot(rest) { + if let Some(name) = self.match_interpreter_script(&rest[i]) { + return Some(name); + } + } } - None + self.match_argv_script(tokens).map(|(name, _slot)| name) } /// The launch flags an agent pane was started with: the argv tokens **after** @@ -1504,6 +1514,21 @@ impl Manifests { } } } + if self + .agents + .iter() + .any(|candidate| candidate.argv_script.is_some()) + { + let argv_low: Vec = std::iter::once(first_low.clone()) + .chain(rest.iter().map(|arg| arg.to_lowercase())) + .collect(); + if let Some((name, slot)) = self.match_argv_script(&argv_low) { + if name == agent { + let argv = std::iter::once(first).chain(rest.iter()); + return Some(argv.skip(slot + 1).map(|s| s.to_string()).collect()); + } + } + } } None } @@ -1609,6 +1634,17 @@ impl Manifests { }) } + /// An adapter's full-argv predicate. The returned index is the script token. + fn match_argv_script(&self, argv: &[String]) -> Option<(String, usize)> { + let agent = self.best_agent(|agent| { + agent + .argv_script + .is_some_and(|matcher| matcher(argv).is_some()) + })?; + let slot = (agent.argv_script?)(argv)?; + Some((agent.name.clone(), slot)) + } + /// The agent whose patterns name exactly this binary. fn match_binary(&self, base: &str) -> Option { if let Some(agent) = @@ -2288,6 +2324,128 @@ Would you like to proceed? ); } + #[test] + fn cursor_agent_command_matches_only_the_install_script() { + let m = Manifests::builtin(); + let unix = "agent --use-system-ca /Users/me/.local/share/cursor-agent/versions/2026.10.19-6bbf2c4/index.js"; + let live = "/Users/me/.local/bin/agent --use-system-ca /Users/me/.local/share/cursor-agent/versions/2026.10.19-6bbf2c4/index.js --continue"; + let windows = r#""C:\Users\Ada Lovelace\AppData\Local\cursor-agent\versions\2026.10.19-6bbf2c4\index.js""#; + assert_eq!( + m.agent_in_processes(&[unix.into()]), + Some("cursor".into()), + "the short agent command is Cursor when it execs the install script" + ); + assert_eq!( + m.agent_in_processes(&[live.into()]), + Some("cursor".into()), + "ps records the launcher's full path as argv0" + ); + assert_eq!( + m.launch_args_for(&[live.into()], "cursor"), + Some(vec!["--continue".into()]) + ); + assert_eq!( + m.agent_in_processes(&["/usr/sbin/distnoted agent".into()]), + None, + "a later argument named agent is not the launcher" + ); + assert_eq!( + m.agent_in_processes(&[ + "node --use-system-ca /Users/me/.local/share/cursor-agent/versions/2026.10.19-6bbf2c4/index.js" + .into() + ]), + Some("cursor".into()), + "Node running the same install script is Cursor" + ); + assert_eq!( + m.agent_in_processes(&[ + r#"node "C:\Users\Ada Lovelace\AppData\Local\cursor-agent\versions\2026.10.19-6bbf2c4\index.js""# + .into() + ]), + Some("cursor".into()) + ); + assert_eq!( + m.agent_in_processes(&[format!("agent.cmd --use-system-ca {windows}")]), + Some("cursor".into()) + ); + assert_eq!( + m.launch_args_for(&[format!("{unix} --resume chat-1")], "cursor"), + Some(vec!["--resume".into(), "chat-1".into()]), + "launcher flags before the script are not user arguments" + ); + assert_eq!(m.agent_in_processes(&["agent".into()]), None); + assert_eq!(m.agent_in_processes(&["agent --help".into()]), None); + assert_eq!( + m.agent_in_processes(&["agent /tmp/index.js".into()]), + None, + "another program named agent is not Cursor" + ); + assert_eq!( + m.agent_in_processes(&[ + "vim /Users/me/.local/share/cursor-agent/versions/2026.10.19-6bbf2c4/index.js" + .into() + ]), + None, + "opening the install script must not name the pane" + ); + assert_eq!( + m.agent_in_processes(&[ + "agent --use-system-ca /home/cursor-agent/.local/share/cursor-agent/versions/2026.10.19/index.js" + .into() + ]), + Some("cursor".into()), + "an earlier cursor-agent directory must not hide the install" + ); + assert_eq!( + m.agent_in_processes(&["node /work/cursor-agent/index.js".into()]), + None + ); + assert_eq!( + m.agent_in_processes(&[ + "node /work/cursor-agent/versions/2026.10.19-6bbf2c4/readme.md".into() + ]), + None + ); + + let incidental = classify( + Some("zsh"), + "start the agent\n", + true, + false, + "zsh", + "", + &["zsh".into()], + &m, + ); + assert_eq!(incidental.agent, "zsh"); + } + + #[test] + fn cursor_identity_replace_disables_the_install_script() { + let mut m = Manifests::builtin(); + toml::from_str::( + r#" + agent = "cursor" + [identity] + distinct = ["cursor-agent"] + replace = true + "#, + ) + .unwrap() + .apply_identity(&mut m.agents); + assert_eq!( + m.agent_in_processes(&[ + "agent --use-system-ca /Users/me/.local/share/cursor-agent/versions/2026.10.19-6bbf2c4/index.js" + .into() + ]), + None + ); + assert_eq!( + m.agent_in_processes(&["cursor-agent".into()]), + Some("cursor".into()) + ); + } + #[test] fn muse_identity_replace_disables_the_versioned_binary_matcher() { let mut m = Manifests::builtin(); diff --git a/website/src/content/docs/docs/extend/adding-agent-support.mdx b/website/src/content/docs/docs/extend/adding-agent-support.mdx index b621b947e..156109062 100644 --- a/website/src/content/docs/docs/extend/adding-agent-support.mdx +++ b/website/src/content/docs/docs/extend/adding-agent-support.mdx @@ -115,6 +115,11 @@ Identity must be conservative because process evidence outranks text: - `overlap_priority`: an explicit tie-breaker for a reviewed collision, not a general ranking system. +Cursor's short command is `agent`. That ordinary word is not an identity +string. The Cursor adapter recognizes the wrapper from its full argv, and a +user manifest with `replace = true` drops that predicate with the rest of +Cursor's built-in identity. + Package scope is part of identity. For example, `@oh-my-pi/pi-coding-agent` is OMP while `@earendil-works/pi-coding-agent` is Pi. Matching only the shared basename diff --git a/website/src/content/docs/docs/guides/agents.mdx b/website/src/content/docs/docs/guides/agents.mdx index 385c6a93c..3704f4e2a 100644 --- a/website/src/content/docs/docs/guides/agents.mdx +++ b/website/src/content/docs/docs/guides/agents.mdx @@ -368,6 +368,10 @@ distinct = ["cursor-agent"] # the CLI binary: unmistakable ambiguous = ["cursor"] # also a normal English word ``` +Cursor's short command is `agent`. Luvus recognizes that process from the +install path `cursor-agent/versions//index.js`. The word `agent` alone +is not an identity, so do not add it to either list. + Naming an agent luvus does not ship teaches it a new one, no rebuild and no waiting for a release: diff --git a/website/src/content/docs/docs/reference/agents.mdx b/website/src/content/docs/docs/reference/agents.mdx index d087ae8b0..763730161 100644 --- a/website/src/content/docs/docs/reference/agents.mdx +++ b/website/src/content/docs/docs/reference/agents.mdx @@ -138,6 +138,12 @@ Luvus resumes an exactly reported Hermes session with `hermes --resume `. Hermes currently exposes branching only from inside its own TUI, so Luvus does not present **Fork to New Pane** for Hermes. +Cursor's short command is `agent`. That process is recognized when its script +is `cursor-agent/versions//index.js`, which is how the installed +launcher execs Node. A different program named `agent` is left alone. Resume +is the command `cursor-agent --resume ` when the session id is already +known; Luvus does not scan Cursor's session store. + Missing your agent? You can add it yourself without waiting for a release: drop an `[identity]` block in `~/.luvus/manifests/.toml` and luvus will recognize it, as described in From 3140311daaa93cfbb57ea4705ae7dc596c4bb00f Mon Sep 17 00:00:00 2001 From: "Paulo R. Kraemer" Date: Wed, 21 Oct 2026 08:51:17 -0300 Subject: [PATCH 2/5] fix(automation): launch scheduled workers under PowerShell (#512) --- src/app/board.rs | 295 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 250 insertions(+), 45 deletions(-) diff --git a/src/app/board.rs b/src/app/board.rs index 02b5b4dd8..25a8a920e 100644 --- a/src/app/board.rs +++ b/src/app/board.rs @@ -49,7 +49,7 @@ struct TaskStartSelection { enum TaskAgentLaunch { Manual { agent: String, briefing: String }, - Automation(String), + Automation(AutomationWorkerLaunch), } pub enum TaskRetryResult { @@ -517,9 +517,11 @@ impl App { Err(error) => Some(("send_failed".to_string(), error.to_string())), } } - TaskAgentLaunch::Automation(line) => submit(worker, line) - .err() - .map(|message| ("send_failed".to_string(), message)), + TaskAgentLaunch::Automation(launch) => { + submit(worker, &launch.line(&worker.command)) + .err() + .map(|message| ("send_failed".to_string(), message)) + } }, None => Some(( "spawn_failed".to_string(), @@ -3007,27 +3009,127 @@ fn manual_agent_launch( }) } +/// The shell family that reads a worker launch line in a new task pane. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WorkerShell { + Posix, + PowerShell, + Cmd, +} + +impl WorkerShell { + /// Unix panes always use POSIX syntax. Windows panes follow the configured + /// shell; anything unrecognised keeps the POSIX form. + fn of(shell: &str) -> Self { + if cfg!(windows) { + Self::classify(shell) + } else { + Self::Posix + } + } + + /// Platform-neutral: split on both separators so a Windows shell path is + /// recognised wherever this runs, including Unix CI. + fn classify(shell: &str) -> Self { + let base = shell + .rsplit(['/', '\\']) + .next() + .unwrap_or(shell) + .to_ascii_lowercase(); + match base.trim_end_matches(".exe") { + "pwsh" | "powershell" => Self::PowerShell, + "cmd" => Self::Cmd, + _ => Self::Posix, + } + } + + /// Quote `s` as one literal argument for this shell. + fn quote(self, s: &str) -> String { + match self { + Self::Posix => format!("'{}'", s.replace('\'', "'\\''")), + // PowerShell single-quoted strings expand nothing; every quote + // character it recognises (including typographic ones) is + // escaped by doubling it. + Self::PowerShell => { + let mut quoted = String::with_capacity(s.len() + 2); + quoted.push('\''); + for c in s.chars() { + if matches!(c, '\'' | '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}') { + quoted.push(c); + } + quoted.push(c); + } + quoted.push('\''); + quoted + } + // cmd has no escape for `"` inside quotes; Windows paths cannot + // contain one, so soften any stray quote rather than break out. + Self::Cmd => format!("\"{}\"", s.replace('"', "'")), + } + } + + /// Leave plain identifier tokens bare and quote anything else. + fn arg(self, s: &str) -> String { + let plain = !s.is_empty() + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')); + if plain { + s.to_string() + } else { + self.quote(s) + } + } + + /// Invoke a quoted program. PowerShell parses a leading quoted string as + /// an expression, so it needs the call operator. + fn invoke(self, program: &str) -> String { + match self { + Self::PowerShell => format!("& {program}"), + Self::Posix | Self::Cmd => program.to_string(), + } + } +} + /// The shell receives only a bounded private-runner command. The runner reloads /// the claimed task and passes its full briefing directly to the agent process. fn manual_agent_runner_line(shell: &str) -> String { - #[cfg(not(windows))] - let _ = shell; - #[cfg(not(windows))] - { - "\"$LUVUS_BIN_PATH\" __task-worker".to_string() + let shell = WorkerShell::of(shell); + let program = match shell { + WorkerShell::PowerShell => "\"$env:LUVUS_BIN_PATH\"", + WorkerShell::Cmd => "\"%LUVUS_BIN_PATH%\"", + WorkerShell::Posix => "\"$LUVUS_BIN_PATH\"", + }; + format!("{} __task-worker", shell.invoke(program)) +} + +/// Validated data for a scheduled worker. The launch line is rendered only +/// once the worker pane exists, because its syntax depends on that pane's +/// shell. +#[derive(Debug)] +struct AutomationWorkerLaunch { + executable: String, + task_id: String, + automation_id: String, + run_id: String, +} + +impl AutomationWorkerLaunch { + fn line(&self, shell: &str) -> String { + self.line_for(WorkerShell::of(shell), !cfg!(windows)) } - #[cfg(windows)] - { - let base = std::path::Path::new(shell) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(shell) - .trim_end_matches(".exe") - .to_ascii_lowercase(); - match base.as_str() { - "pwsh" | "powershell" => "& \"$env:LUVUS_BIN_PATH\" __task-worker".to_string(), - "cmd" => "\"%LUVUS_BIN_PATH%\" __task-worker".to_string(), - _ => "\"$LUVUS_BIN_PATH\" __task-worker".to_string(), + + fn line_for(&self, shell: WorkerShell, task_id_prefix: bool) -> String { + let command = format!( + "{} __automation-worker {} {} {}", + shell.invoke(&shell.quote(&self.executable)), + shell.arg(&self.task_id), + shell.arg(&self.automation_id), + shell.arg(&self.run_id), + ); + if task_id_prefix && shell == WorkerShell::Posix { + format!("LUVUS_TASK_ID={} {command}", shell.arg(&self.task_id)) + } else { + command } } } @@ -3036,7 +3138,7 @@ fn automation_agent_launch_line( agent: &str, task: &crate::orch::Task, access: crate::automation::AutomationAccess, -) -> Result { +) -> Result { let descriptor = crate::agent::registry::find(agent).ok_or_else(|| format!("unsupported agent: {agent}"))?; // Validate the adapter/access pair before anything is created. The private @@ -3052,16 +3154,12 @@ fn automation_agent_launch_line( // sessions cannot accidentally route through a different Luvus install. let executable = std::env::current_exe() .map_err(|error| format!("could not resolve the Luvus automation runner: {error}"))?; - let executable = shell_quote(&executable.to_string_lossy()); - let command = format!( - "{executable} __automation-worker {} {} {}", - task.id, provenance.automation_id, provenance.run_id - ); - if cfg!(windows) { - Ok(command) - } else { - Ok(format!("LUVUS_TASK_ID={} {command}", task.id)) - } + Ok(AutomationWorkerLaunch { + executable: executable.to_string_lossy().into_owned(), + task_id: task.id.clone(), + automation_id: provenance.automation_id.clone(), + run_id: provenance.run_id.clone(), + }) } fn agent_automation_command( @@ -3086,17 +3184,6 @@ fn agent_automation_command( .join(" ")) } -/// Quote `s` as one shell argument: POSIX single-quoting on Unix; on Windows -/// (cmd/PowerShell have no safe common quoting) double quotes with inner -/// double quotes softened to single quotes. -fn shell_quote(s: &str) -> String { - if cfg!(windows) { - format!("\"{}\"", s.replace('"', "'")) - } else { - format!("'{}'", s.replace('\'', "'\\''")) - } -} - struct TaskMergeJob { task: String, branch: String, @@ -4708,8 +4795,9 @@ mod tests { ) .unwrap(); let task = state.task(&task.id).unwrap(); - let line = + let launch = automation_agent_launch_line("codex", task, AutomationAccess::Workspace).unwrap(); + let line = launch.line("pwsh"); assert!(line.contains("__automation-worker t1 automation_1 run_1")); assert!(!line.contains("LUVUS_BIN_PATH")); assert!(!line.contains("review safely")); @@ -4717,6 +4805,122 @@ mod tests { assert!(!line.contains("codex exec")); } + fn worker_launch(executable: &str) -> AutomationWorkerLaunch { + AutomationWorkerLaunch { + executable: executable.into(), + task_id: "t1".into(), + automation_id: "automation_1".into(), + run_id: "run_1".into(), + } + } + + #[test] + fn worker_shell_classifies_configured_windows_shells() { + for shell in [ + "pwsh", + "pwsh.exe", + "PWSH.EXE", + "powershell", + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", + r"C:\Program Files\PowerShell\7\pwsh.exe", + ] { + assert_eq!( + WorkerShell::classify(shell), + WorkerShell::PowerShell, + "{shell}" + ); + } + for shell in ["cmd", "cmd.exe", r"C:\Windows\System32\cmd.exe"] { + assert_eq!(WorkerShell::classify(shell), WorkerShell::Cmd, "{shell}"); + } + for shell in ["bash", "/bin/zsh", "fish", "nu", ""] { + assert_eq!(WorkerShell::classify(shell), WorkerShell::Posix, "{shell}"); + } + if !cfg!(windows) { + assert_eq!(WorkerShell::of("pwsh"), WorkerShell::Posix); + } + } + + #[test] + fn automation_worker_line_uses_the_powershell_call_operator() { + let launch = worker_launch(r"C:\Program Files\Luvus\luvus.exe"); + assert_eq!( + launch.line_for(WorkerShell::PowerShell, false), + r"& 'C:\Program Files\Luvus\luvus.exe' __automation-worker t1 automation_1 run_1" + ); + // A single-quoted PowerShell string expands nothing, and doubling is + // its only escape, so `$`, backticks, and quotes stay literal. + let launch = worker_launch(r"C:\Users\O'Neil $x `y\luvus.exe"); + assert_eq!( + launch.line_for(WorkerShell::PowerShell, false), + r"& 'C:\Users\O''Neil $x `y\luvus.exe' __automation-worker t1 automation_1 run_1" + ); + let mut odd = worker_launch(r"C:\luvus.exe"); + odd.run_id = "run 1;rm".into(); + odd.automation_id = "it\u{2019}s".into(); + assert_eq!( + odd.line_for(WorkerShell::PowerShell, false), + "& 'C:\\luvus.exe' __automation-worker t1 'it\u{2019}\u{2019}s' 'run 1;rm'" + ); + } + + #[test] + fn automation_worker_line_for_cmd_quotes_without_call_operator() { + let launch = worker_launch(r"C:\Program Files\Luvus\luvus.exe"); + assert_eq!( + launch.line_for(WorkerShell::Cmd, false), + r#""C:\Program Files\Luvus\luvus.exe" __automation-worker t1 automation_1 run_1"# + ); + let mut odd = worker_launch(r"C:\luvus.exe"); + odd.run_id = "run 1&x".into(); + assert_eq!( + odd.line_for(WorkerShell::Cmd, false), + r#""C:\luvus.exe" __automation-worker t1 automation_1 "run 1&x""# + ); + } + + #[test] + fn automation_worker_line_for_posix_shells_single_quotes() { + let launch = worker_launch("/opt/my luvus/it's/luvus"); + assert_eq!( + launch.line_for(WorkerShell::Posix, true), + r"LUVUS_TASK_ID=t1 '/opt/my luvus/it'\''s/luvus' __automation-worker t1 automation_1 run_1" + ); + // Git Bash on Windows keeps the prefix-free form used before. + assert_eq!( + worker_launch(r"C:\Program Files\Luvus\luvus.exe").line_for(WorkerShell::Posix, false), + r"'C:\Program Files\Luvus\luvus.exe' __automation-worker t1 automation_1 run_1" + ); + let mut odd = worker_launch("/bin/luvus"); + odd.task_id = "t 1".into(); + assert_eq!( + odd.line_for(WorkerShell::Posix, true), + "LUVUS_TASK_ID='t 1' '/bin/luvus' __automation-worker 't 1' automation_1 run_1" + ); + } + + #[test] + fn manual_runner_line_matches_the_shell_family() { + if cfg!(windows) { + assert_eq!( + manual_agent_runner_line("pwsh"), + "& \"$env:LUVUS_BIN_PATH\" __task-worker" + ); + assert_eq!( + manual_agent_runner_line("powershell.exe"), + "& \"$env:LUVUS_BIN_PATH\" __task-worker" + ); + assert_eq!( + manual_agent_runner_line("cmd.exe"), + "\"%LUVUS_BIN_PATH%\" __task-worker" + ); + } + assert_eq!( + manual_agent_runner_line("bash"), + "\"$LUVUS_BIN_PATH\" __task-worker" + ); + } + #[test] fn start_picker_agents_follow_the_builtin_registry() { let choices = agent_choices(); @@ -4754,7 +4958,8 @@ mod tests { #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] #[test] fn gate_command_runner_terminates_a_detached_session() { - let executable = shell_quote(&std::env::current_exe().unwrap().to_string_lossy()); + let executable = + WorkerShell::Posix.quote(&std::env::current_exe().unwrap().to_string_lossy()); let command = format!( "LUVUS_GATE_DETACH_HELPER=1 {executable} --exact app::board::tests::gate_detached_child_helper & sleep 0.05; exit 0" ); From c12514338b348470f844c6859196cb5901809f20 Mon Sep 17 00:00:00 2001 From: Riz <2667489+RizRiyz@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:54:16 +0700 Subject: [PATCH 3/5] fix(web): improve mobile terminal input and modifier controls (#503) --- examples/uhp/terminal/consumer.py | 3 + protocol/uhp/v1/terminal/README.md | 14 +- .../fixtures/invalid/control-frames.jsonl | 2 +- .../uhp/v1/terminal/fixtures/manifest.json | 2 +- .../fixtures/valid/control-frames.jsonl | 3 + .../terminal/fixtures/valid/responses.jsonl | 2 +- .../terminal/schema/control-frame.schema.json | 2 +- .../schema/methods/send-key.schema.json | 2 +- src/api/capabilities.rs | 2 +- src/app/backend.rs | 156 ++++++----- src/app/input.rs | 26 +- src/terminal/backend/keys.rs | 156 +++++++++++ src/terminal/backend/mod.rs | 5 +- src/terminal/pty.rs | 10 - src/uhp/gateway.rs | 63 +---- src/web/assets/app.css | 2 +- src/web/assets/app.js | 8 +- web/packages/app/src/app.ts | 38 ++- web/packages/app/src/native-input.ts | 156 +++++++++-- web/packages/app/src/native-keymap.ts | 30 +++ web/packages/app/src/styles.css | 33 ++- web/packages/app/src/terminal-view.ts | 41 ++- web/packages/app/test/native-input.test.mjs | 252 ++++++++++++++++++ web/packages/app/test/native-keymap.test.mjs | 29 +- web/packages/app/tsconfig.test.json | 2 +- web/scripts/check-browser-layout.mjs | 188 ++++++++++++- web/scripts/test-browser-pairing.mjs | 65 ++++- website/src/content/docs/docs/guides/web.mdx | 16 ++ .../src/content/docs/docs/uhp/terminal.mdx | 15 +- 29 files changed, 1142 insertions(+), 181 deletions(-) create mode 100644 src/terminal/backend/keys.rs create mode 100644 web/packages/app/test/native-input.test.mjs diff --git a/examples/uhp/terminal/consumer.py b/examples/uhp/terminal/consumer.py index 3101dfdfd..79e0f79ac 100644 --- a/examples/uhp/terminal/consumer.py +++ b/examples/uhp/terminal/consumer.py @@ -18,6 +18,9 @@ REQUEST_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$") BASE64 = re.compile(r"^[A-Za-z0-9+/]+={0,2}$") KEYS = {"enter", "escape", "tab", "backtab", "up", "down", "left", "right", "home", "end", "backspace", "delete", "pageup", "pagedown", "ctrl-a", "ctrl-b", "ctrl-c", "ctrl-d", "ctrl-e", "ctrl-f", "ctrl-g", "ctrl-k", "ctrl-l", "ctrl-n", "ctrl-o", "ctrl-p", "ctrl-r", "ctrl-t", "ctrl-u", "ctrl-w", "ctrl-y", "alt-d", "space", *(f"digit-{n}" for n in range(10))} +KEYS.update(f"{prefix}{letter}" for prefix in ("ctrl-", "ctrl-shift-") for letter in "abcdefghijklmnopqrstuvwxyz") +KEYS.update(f"{prefix}{key}" for prefix in ("ctrl-", "shift-", "ctrl-shift-") for key in ("enter", "tab", "up", "down", "left", "right", "home", "end", "pageup", "pagedown")) +KEYS.update(("ctrl-space", "ctrl-shift-space")) METHOD_FIELDS = { "uhp.capabilities": set(), "terminal.backend.inventory": set(), diff --git a/protocol/uhp/v1/terminal/README.md b/protocol/uhp/v1/terminal/README.md index 68e347997..8354bcbed 100644 --- a/protocol/uhp/v1/terminal/README.md +++ b/protocol/uhp/v1/terminal/README.md @@ -69,8 +69,18 @@ The bounded `send_key` vocabulary includes `ctrl-w` and `alt-d` for backward and forward word deletion plus `ctrl-u` and `ctrl-k` for deletion toward the start and end of the current line. It also carries the common shell and agent controls `ctrl-a`, `ctrl-b`, `ctrl-e`, `ctrl-f`, `ctrl-g`, `ctrl-l`, `ctrl-n`, -`ctrl-o`, `ctrl-p`, `ctrl-r`, `ctrl-t`, and `ctrl-y`. Job-control and -flow-control keys such as `ctrl-z`, `ctrl-s`, and `ctrl-q` remain excluded. +`ctrl-o`, `ctrl-p`, `ctrl-r`, `ctrl-t`, and `ctrl-y`. Without the +`modified_keys` feature, job-control and flow-control keys such as `ctrl-z`, +`ctrl-s`, and `ctrl-q` remain excluded. + +With `terminal.features: ["modified_keys", ...]`, it also accepts `ctrl-a` +through `ctrl-z`, their `ctrl-shift-` variants, `ctrl-space` and +`ctrl-shift-space`. Navigation, Enter and Tab accept `ctrl-`, `shift-` and +`ctrl-shift-` prefixes. Encoding uses the pane's negotiated keyboard protocol +and Luvus's configured Shift+Enter sequence, just like TUI input. No raw escape +sequences or arbitrary key names are accepted. +Each `send_key` action is one complete tap; when the child negotiates Kitty +event reporting, supported keys include both press and release events. Protocol 1.0 capture includes a monotonic `content_revision` and provides a sequence-fenced snapshot, bounded terminal-only event streams, and event-driven diff --git a/protocol/uhp/v1/terminal/fixtures/invalid/control-frames.jsonl b/protocol/uhp/v1/terminal/fixtures/invalid/control-frames.jsonl index d6ec41f66..4ad456d80 100644 --- a/protocol/uhp/v1/terminal/fixtures/invalid/control-frames.jsonl +++ b/protocol/uhp/v1/terminal/fixtures/invalid/control-frames.jsonl @@ -1,5 +1,5 @@ {"id":"bad-1","action":"type_literal","params":{"text":"","extra":true}} -{"id":"bad-2","action":"send_key","params":{"key":"ctrl-z"}} +{"id":"bad-2","action":"send_key","params":{"key":"raw-bytes"}} {"id":"bad-3","action":"run","params":{"command":"rm"}} {"id":"unicode-é","action":"send_key","params":{"key":"enter"}} {"id":"bad-image-base64","action":"paste_image","params":{"png_base64":"!!!!"}} diff --git a/protocol/uhp/v1/terminal/fixtures/manifest.json b/protocol/uhp/v1/terminal/fixtures/manifest.json index 7430b6ffc..478fc7163 100644 --- a/protocol/uhp/v1/terminal/fixtures/manifest.json +++ b/protocol/uhp/v1/terminal/fixtures/manifest.json @@ -4,7 +4,7 @@ {"path":"valid/requests.jsonl","kind":"request","expect":"valid","count":20}, {"path":"valid/responses.jsonl","kind":"response","expect":"valid","count":15}, {"path":"valid/events.jsonl","kind":"event","expect":"valid","count":6}, - {"path":"valid/control-frames.jsonl","kind":"control","expect":"valid","count":13}, + {"path":"valid/control-frames.jsonl","kind":"control","expect":"valid","count":16}, {"path":"invalid/requests.jsonl","kind":"request","expect":"invalid","count":11}, {"path":"invalid/responses.jsonl","kind":"response","expect":"invalid","count":1}, {"path":"invalid/events.jsonl","kind":"event","expect":"invalid","count":3}, diff --git a/protocol/uhp/v1/terminal/fixtures/valid/control-frames.jsonl b/protocol/uhp/v1/terminal/fixtures/valid/control-frames.jsonl index cd18ee57f..90e88fcf5 100644 --- a/protocol/uhp/v1/terminal/fixtures/valid/control-frames.jsonl +++ b/protocol/uhp/v1/terminal/fixtures/valid/control-frames.jsonl @@ -10,4 +10,7 @@ {"id":"input-4","action":"send_key","params":{"key":"ctrl-k"}} {"id":"input-5","action":"send_key","params":{"key":"alt-d"}} {"id":"input-6","action":"send_key","params":{"key":"ctrl-r"}} +{"id":"modifier-1","action":"send_key","params":{"key":"ctrl-a"}} +{"id":"modifier-2","action":"send_key","params":{"key":"shift-enter"}} +{"id":"modifier-3","action":"send_key","params":{"key":"ctrl-shift-left"}} {"id":"size-1","action":"set_viewport","params":{"cols":40,"rows":18}} diff --git a/protocol/uhp/v1/terminal/fixtures/valid/responses.jsonl b/protocol/uhp/v1/terminal/fixtures/valid/responses.jsonl index 9002bbae8..238ac78b9 100644 --- a/protocol/uhp/v1/terminal/fixtures/valid/responses.jsonl +++ b/protocol/uhp/v1/terminal/fixtures/valid/responses.jsonl @@ -1,4 +1,4 @@ -{"id":"cap-1","result":{"type":"uhp_capabilities","protocol":{"name":"luvus-uhp","major":1,"minor":0},"server_generation":"11111111111111111111111111111111","session":"default","event_sequence":0,"methods":[],"method_contracts":[],"limits":{},"terminal":{"capabilities":["inventory","validate","capture","observe","control_stream","type_literal","paste_text","submit_text","send_key","set_title","notify_terminal","create_workspace","create_sibling","close","snapshot","events","wait_change","wait_output","process_inspection","paste_image","upload_start","upload_chunk","upload_finish","upload_cancel","set_viewport"],"features":["stream_cursor","create_restore_policy"],"limits":{"request_bytes":1048576,"response_bytes":1048576,"inventory_terminals":1024,"capture_lines":300,"capture_bytes":524288,"observe_bytes":65536,"observe_lines":200,"observers":8,"observer_queue":2,"input_bytes":262144,"logical_keys_per_request":1,"viewport_cols":500,"viewport_rows":300,"title_bytes":256,"notification_title_bytes":256,"notification_body_bytes":8192,"command_args":128,"command_bytes":65536,"command_arg_bytes":16384,"cwd_bytes":4096,"event_queue":256,"event_subscribers":64,"wait_timeout_ms":300000,"queued_input_bytes":8388608,"queued_input_actions":4096}}}} +{"id":"cap-1","result":{"type":"uhp_capabilities","protocol":{"name":"luvus-uhp","major":1,"minor":0},"server_generation":"11111111111111111111111111111111","session":"default","event_sequence":0,"methods":[],"method_contracts":[],"limits":{},"terminal":{"capabilities":["inventory","validate","capture","observe","control_stream","type_literal","paste_text","submit_text","send_key","set_title","notify_terminal","create_workspace","create_sibling","close","snapshot","events","wait_change","wait_output","process_inspection","paste_image","upload_start","upload_chunk","upload_finish","upload_cancel","set_viewport"],"features":["stream_cursor","create_restore_policy","modified_keys"],"limits":{"request_bytes":1048576,"response_bytes":1048576,"inventory_terminals":1024,"capture_lines":300,"capture_bytes":524288,"observe_bytes":65536,"observe_lines":200,"observers":8,"observer_queue":2,"input_bytes":262144,"logical_keys_per_request":1,"viewport_cols":500,"viewport_rows":300,"title_bytes":256,"notification_title_bytes":256,"notification_body_bytes":8192,"command_args":128,"command_bytes":65536,"command_arg_bytes":16384,"cwd_bytes":4096,"event_queue":256,"event_subscribers":64,"wait_timeout_ms":300000,"queued_input_bytes":8388608,"queued_input_actions":4096}}}} {"id":"inv-1","result":{"type":"terminal_backend_inventory","server_generation":"11111111111111111111111111111111","terminals":[],"truncated":false}} {"id":"val-1","result":{"type":"terminal_backend_validation","state":"alive"}} {"id":"proc-1","result":{"type":"terminal_backend_processes","server_generation":"11111111111111111111111111111111","terminal_id":"22222222222222222222222222222222","pane_id":"7","root_process":{"pid":1234,"start_marker":"linux:100"},"scan":"observed","executables":["zsh","codex"],"arguments_exposed":false}} diff --git a/protocol/uhp/v1/terminal/schema/control-frame.schema.json b/protocol/uhp/v1/terminal/schema/control-frame.schema.json index 0e48815ac..e64fa4043 100644 --- a/protocol/uhp/v1/terminal/schema/control-frame.schema.json +++ b/protocol/uhp/v1/terminal/schema/control-frame.schema.json @@ -61,7 +61,7 @@ { "properties":{ "action":{"const":"send_key"}, - "params":{"type":"object","properties":{"key":{"enum":["enter","escape","tab","backtab","up","down","left","right","home","end","backspace","delete","pageup","pagedown","ctrl-a","ctrl-b","ctrl-c","ctrl-d","ctrl-e","ctrl-f","ctrl-g","ctrl-k","ctrl-l","ctrl-n","ctrl-o","ctrl-p","ctrl-r","ctrl-t","ctrl-u","ctrl-w","ctrl-y","alt-d","space","digit-0","digit-1","digit-2","digit-3","digit-4","digit-5","digit-6","digit-7","digit-8","digit-9"]}},"required":["key"],"additionalProperties":false} + "params":{"type":"object","properties":{"key":{"enum":["enter","escape","tab","backtab","up","down","left","right","home","end","backspace","delete","pageup","pagedown","ctrl-c","ctrl-d","ctrl-k","ctrl-u","ctrl-w","alt-d","space","digit-0","digit-1","digit-2","digit-3","digit-4","digit-5","digit-6","digit-7","digit-8","digit-9","ctrl-a","ctrl-b","ctrl-e","ctrl-f","ctrl-g","ctrl-h","ctrl-i","ctrl-j","ctrl-l","ctrl-m","ctrl-n","ctrl-o","ctrl-p","ctrl-q","ctrl-r","ctrl-s","ctrl-t","ctrl-v","ctrl-x","ctrl-y","ctrl-z","ctrl-space","ctrl-shift-a","ctrl-shift-b","ctrl-shift-c","ctrl-shift-d","ctrl-shift-e","ctrl-shift-f","ctrl-shift-g","ctrl-shift-h","ctrl-shift-i","ctrl-shift-j","ctrl-shift-k","ctrl-shift-l","ctrl-shift-m","ctrl-shift-n","ctrl-shift-o","ctrl-shift-p","ctrl-shift-q","ctrl-shift-r","ctrl-shift-s","ctrl-shift-t","ctrl-shift-u","ctrl-shift-v","ctrl-shift-w","ctrl-shift-x","ctrl-shift-y","ctrl-shift-z","ctrl-shift-space","ctrl-enter","ctrl-tab","ctrl-up","ctrl-down","ctrl-left","ctrl-right","ctrl-home","ctrl-end","ctrl-pageup","ctrl-pagedown","shift-enter","shift-tab","shift-up","shift-down","shift-left","shift-right","shift-home","shift-end","shift-pageup","shift-pagedown","ctrl-shift-enter","ctrl-shift-tab","ctrl-shift-up","ctrl-shift-down","ctrl-shift-left","ctrl-shift-right","ctrl-shift-home","ctrl-shift-end","ctrl-shift-pageup","ctrl-shift-pagedown"]}},"required":["key"],"additionalProperties":false} } } ] diff --git a/protocol/uhp/v1/terminal/schema/methods/send-key.schema.json b/protocol/uhp/v1/terminal/schema/methods/send-key.schema.json index 4572b1d53..d36c8ed65 100644 --- a/protocol/uhp/v1/terminal/schema/methods/send-key.schema.json +++ b/protocol/uhp/v1/terminal/schema/methods/send-key.schema.json @@ -1 +1 @@ -{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"server_generation":{"type":"string","pattern":"^[0-9a-f]{32}$"},"terminal_id":{"type":"string","pattern":"^[0-9a-f]{32}$"},"pane_id":{"type":"string","pattern":"^[1-9][0-9]{0,9}$"},"expected_root":{"$ref":"../common.schema.json#/$defs/expectedRoot"},"key":{"enum":["enter","escape","tab","backtab","up","down","left","right","home","end","backspace","delete","pageup","pagedown","ctrl-a","ctrl-b","ctrl-c","ctrl-d","ctrl-e","ctrl-f","ctrl-g","ctrl-k","ctrl-l","ctrl-n","ctrl-o","ctrl-p","ctrl-r","ctrl-t","ctrl-u","ctrl-w","ctrl-y","alt-d","space","digit-0","digit-1","digit-2","digit-3","digit-4","digit-5","digit-6","digit-7","digit-8","digit-9"]}},"required":["server_generation","terminal_id","pane_id","key"],"additionalProperties":false} +{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"server_generation":{"type":"string","pattern":"^[0-9a-f]{32}$"},"terminal_id":{"type":"string","pattern":"^[0-9a-f]{32}$"},"pane_id":{"type":"string","pattern":"^[1-9][0-9]{0,9}$"},"expected_root":{"$ref":"../common.schema.json#/$defs/expectedRoot"},"key":{"enum":["enter","escape","tab","backtab","up","down","left","right","home","end","backspace","delete","pageup","pagedown","ctrl-c","ctrl-d","ctrl-k","ctrl-u","ctrl-w","alt-d","space","digit-0","digit-1","digit-2","digit-3","digit-4","digit-5","digit-6","digit-7","digit-8","digit-9","ctrl-a","ctrl-b","ctrl-e","ctrl-f","ctrl-g","ctrl-h","ctrl-i","ctrl-j","ctrl-l","ctrl-m","ctrl-n","ctrl-o","ctrl-p","ctrl-q","ctrl-r","ctrl-s","ctrl-t","ctrl-v","ctrl-x","ctrl-y","ctrl-z","ctrl-space","ctrl-shift-a","ctrl-shift-b","ctrl-shift-c","ctrl-shift-d","ctrl-shift-e","ctrl-shift-f","ctrl-shift-g","ctrl-shift-h","ctrl-shift-i","ctrl-shift-j","ctrl-shift-k","ctrl-shift-l","ctrl-shift-m","ctrl-shift-n","ctrl-shift-o","ctrl-shift-p","ctrl-shift-q","ctrl-shift-r","ctrl-shift-s","ctrl-shift-t","ctrl-shift-u","ctrl-shift-v","ctrl-shift-w","ctrl-shift-x","ctrl-shift-y","ctrl-shift-z","ctrl-shift-space","ctrl-enter","ctrl-tab","ctrl-up","ctrl-down","ctrl-left","ctrl-right","ctrl-home","ctrl-end","ctrl-pageup","ctrl-pagedown","shift-enter","shift-tab","shift-up","shift-down","shift-left","shift-right","shift-home","shift-end","shift-pageup","shift-pagedown","ctrl-shift-enter","ctrl-shift-tab","ctrl-shift-up","ctrl-shift-down","ctrl-shift-left","ctrl-shift-right","ctrl-shift-home","ctrl-shift-end","ctrl-shift-pageup","ctrl-shift-pagedown"]}},"required":["server_generation","terminal_id","pane_id","key"],"additionalProperties":false} diff --git a/src/api/capabilities.rs b/src/api/capabilities.rs index ce0be10fe..58749d49d 100644 --- a/src/api/capabilities.rs +++ b/src/api/capabilities.rs @@ -492,7 +492,7 @@ mod tests { assert_eq!(capabilities["limits"]["agent_row_title_agent_bytes"], 64); assert_eq!( capabilities["terminal"]["features"], - json!(["stream_cursor", "create_restore_policy"]) + json!(["stream_cursor", "create_restore_policy", "modified_keys"]) ); let terminal_capabilities = capabilities["terminal"]["capabilities"] .as_array() diff --git a/src/app/backend.rs b/src/app/backend.rs index 86dc48ed0..8a034fae5 100644 --- a/src/app/backend.rs +++ b/src/app/backend.rs @@ -371,7 +371,14 @@ impl App { let pane_id = self.resolve_backend_runtime(params, true)?; let key = required_bounded_string(params, "key", 32, true)?; let pane = &self.panes[&pane_id]; - let bytes = backend_key_bytes(key, pane.application_cursor()).ok_or_else(|| { + let modes = pane.key_encoding_modes(); + let bytes = backend_key_bytes( + key, + modes.application_cursor, + modes.protocol, + self.config.shift_enter_bytes(), + ) + .ok_or_else(|| { BackendError::mutation( "invalid_params", "unsupported logical key", @@ -1704,60 +1711,26 @@ pub(super) fn bounded_text(text: &str, max_bytes: usize) -> String { .collect() } -fn backend_key_bytes(key: &str, application_cursor: bool) -> Option> { - let sequence: &[u8] = match key { - "enter" => b"\r", - "escape" => b"\x1b", - "tab" => b"\t", - "backtab" => b"\x1b[Z", - "up" if application_cursor => b"\x1bOA", - "up" => b"\x1b[A", - "down" if application_cursor => b"\x1bOB", - "down" => b"\x1b[B", - "right" if application_cursor => b"\x1bOC", - "right" => b"\x1b[C", - "left" if application_cursor => b"\x1bOD", - "left" => b"\x1b[D", - "home" if application_cursor => b"\x1bOH", - "home" => b"\x1b[H", - "end" if application_cursor => b"\x1bOF", - "end" => b"\x1b[F", - "backspace" => b"\x7f", - "delete" => b"\x1b[3~", - "pageup" => b"\x1b[5~", - "pagedown" => b"\x1b[6~", - "ctrl-a" => b"\x01", - "ctrl-b" => b"\x02", - "ctrl-c" => b"\x03", - "ctrl-d" => b"\x04", - "ctrl-e" => b"\x05", - "ctrl-f" => b"\x06", - "ctrl-g" => b"\x07", - "ctrl-k" => b"\x0b", - "ctrl-l" => b"\x0c", - "ctrl-n" => b"\x0e", - "ctrl-o" => b"\x0f", - "ctrl-p" => b"\x10", - "ctrl-r" => b"\x12", - "ctrl-t" => b"\x14", - "ctrl-u" => b"\x15", - "ctrl-w" => b"\x17", - "ctrl-y" => b"\x19", - "alt-d" => b"\x1bd", - "space" => b" ", - "digit-0" => b"0", - "digit-1" => b"1", - "digit-2" => b"2", - "digit-3" => b"3", - "digit-4" => b"4", - "digit-5" => b"5", - "digit-6" => b"6", - "digit-7" => b"7", - "digit-8" => b"8", - "digit-9" => b"9", - _ => return None, - }; - Some(sequence.to_vec()) +fn backend_key_bytes( + key: &str, + application_cursor: bool, + protocol: crate::terminal::keyboard::KeyboardProtocol, + newline: &[u8], +) -> Option> { + let event = backend::control_key(key)?; + let mut bytes = + super::input::encode_key_with_modes(&event, newline, application_cursor, protocol)?; + // A logical stream key is a complete tap, not a held physical key. Apps + // requesting Kitty event types must not be left with a pressed key forever. + if protocol.reports_event_types() { + let release = KeyEvent::new_with_kind(event.code, event.modifiers, KeyEventKind::Release); + if let Some(released) = + super::input::encode_key_with_modes(&release, newline, application_cursor, protocol) + { + bytes.extend(released); + } + } + Some(bytes) } #[cfg(test)] @@ -2514,15 +2487,70 @@ mod tests { #[test] fn logical_keys_are_strict_and_mode_aware() { - assert_eq!(backend_key_bytes("left", false).unwrap(), b"\x1b[D"); - assert_eq!(backend_key_bytes("left", true).unwrap(), b"\x1bOD"); - assert_eq!(backend_key_bytes("ctrl-k", false).unwrap(), b"\x0b"); - assert_eq!(backend_key_bytes("alt-d", false).unwrap(), b"\x1bd"); - assert_eq!(backend_key_bytes("ctrl-a", false).unwrap(), b"\x01"); - assert_eq!(backend_key_bytes("ctrl-r", false).unwrap(), b"\x12"); - assert_eq!(backend_key_bytes("ctrl-y", false).unwrap(), b"\x19"); - assert!(backend_key_bytes("ctrl-z", false).is_none()); - assert!(backend_key_bytes("raw-escape", false).is_none()); + use crate::terminal::keyboard::{KeyboardProtocol, KittyKeyboardFlags}; + let legacy = |key: &str, app_cursor: bool| { + backend_key_bytes(key, app_cursor, KeyboardProtocol::Legacy, b"\n") + }; + assert_eq!(legacy("left", false).unwrap(), b"\x1b[D"); + assert_eq!(legacy("left", true).unwrap(), b"\x1bOD"); + assert_eq!(legacy("ctrl-k", false).unwrap(), b"\x0b"); + assert_eq!(legacy("alt-d", false).unwrap(), b"\x1bd"); + assert_eq!(legacy("ctrl-a", false).unwrap(), b"\x01"); + for letter in b'a'..=b'z' { + let key = format!("ctrl-{}", char::from(letter)); + assert_eq!(legacy(&key, false).unwrap(), [letter - b'a' + 1]); + } + assert_eq!(legacy("ctrl-space", false).unwrap(), b"\x00"); + assert_eq!(legacy("ctrl-shift-space", false).unwrap(), b"\x00"); + assert_eq!(legacy("ctrl-left", true).unwrap(), b"\x1b[1;5D"); + assert_eq!(legacy("ctrl-shift-right", false).unwrap(), b"\x1b[1;6C"); + assert_eq!(legacy("shift-enter", false).unwrap(), b"\n"); + assert_eq!(legacy("shift-tab", false).unwrap(), b"\x1b[Z"); + assert!(legacy("raw-escape", false).is_none()); + let kitty = KeyboardProtocol::Kitty { + flags: KittyKeyboardFlags::DISAMBIGUATE_ESCAPE_CODES, + }; + for (key, bytes) in [ + ("ctrl-a", &b"\x1b[97;5u"[..]), + ("ctrl-space", &b"\x1b[32;5u"[..]), + ("ctrl-shift-space", &b"\x1b[32;6u"[..]), + ("shift-enter", &b"\x1b[13;2u"[..]), + ("ctrl-enter", &b"\x1b[13;5u"[..]), + ("ctrl-shift-enter", &b"\x1b[13;6u"[..]), + ] { + assert_eq!(backend_key_bytes(key, false, kitty, b"\n").unwrap(), bytes); + } + let report_all = KeyboardProtocol::Kitty { + flags: KittyKeyboardFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES, + }; + assert_eq!( + backend_key_bytes("ctrl-shift-space", false, report_all, b"\n").unwrap(), + b"\x1b[32;6u" + ); + let events = KeyboardProtocol::Kitty { + flags: KittyKeyboardFlags::DISAMBIGUATE_ESCAPE_CODES + | KittyKeyboardFlags::REPORT_EVENT_TYPES, + }; + assert_eq!( + backend_key_bytes("ctrl-enter", false, events, b"\n").unwrap(), + b"\x1b[13;5u\x1b[13;5:3u" + ); + assert_eq!( + backend_key_bytes("ctrl-shift-a", false, events, b"\n").unwrap(), + b"\x1b[97;6u\x1b[97;6:3u" + ); + assert_eq!( + backend_key_bytes("ctrl-shift-space", false, events, b"\n").unwrap(), + b"\x1b[32;6u\x1b[32;6:3u" + ); + assert_eq!( + backend_key_bytes("ctrl-left", false, events, b"\n").unwrap(), + b"\x1b[1;5D\x1b[1;5:3D" + ); + assert_eq!( + backend_key_bytes("backspace", false, events, b"\n").unwrap(), + b"\x7f" + ); } #[test] diff --git a/src/app/input.rs b/src/app/input.rs index 8f47a3e39..137e9f0e7 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -5640,7 +5640,7 @@ fn encode_key( encode_key_with_modes(key, newline, app_cursor, protocol) } -fn encode_key_with_modes( +pub(super) fn encode_key_with_modes( key: &KeyEvent, newline: &[u8], app_cursor: bool, @@ -5868,12 +5868,16 @@ fn encode_key_with_modes( /// Shift modifier, not the unshifted key identity Kitty requires. Keep those /// combinations on the pre-existing legacy path until the Windows input layer /// can preserve the virtual-key or scan-code identity alongside the event. +/// Space keeps its canonical identity even with Shift held. fn modified_char_has_canonical_identity( character: char, modifiers: KeyModifiers, windows: bool, ) -> bool { - !windows || !modifiers.contains(KeyModifiers::SHIFT) || character.is_alphabetic() + !windows + || !modifiers.contains(KeyModifiers::SHIFT) + || character.is_alphabetic() + || character == ' ' } /// Lowercase a key identity only when Unicode maps it to exactly one scalar. @@ -8557,6 +8561,24 @@ mod tests { assert!(modified_char_has_canonical_identity('?', alt_shift, false)); } + #[test] + fn windows_modified_space_preserves_canonical_identity() { + let modifiers = KeyModifiers::CONTROL | KeyModifiers::SHIFT; + assert!(modified_char_has_canonical_identity(' ', modifiers, true)); + let key = KeyEvent::new(KeyCode::Char(' '), modifiers); + for (disambiguate, report_all) in [(true, false), (false, true)] { + assert!(kitty_key_supports_event_types( + &key, + disambiguate, + report_all, + true, + false, + false, + true, + )); + } + } + #[test] fn control_slash_reaches_nested_tuis_across_terminal_encodings() { let encode = |character, disambiguate| { diff --git a/src/terminal/backend/keys.rs b/src/terminal/backend/keys.rs new file mode 100644 index 000000000..d2b2c1f3a --- /dev/null +++ b/src/terminal/backend/keys.rs @@ -0,0 +1,156 @@ +//! The bounded logical-key contract shared by the backend and access gateway. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +pub(crate) fn control_key(key: &str) -> Option { + if key == "alt-d" { + return Some(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::ALT)); + } + let (name, modifiers) = if let Some(name) = key.strip_prefix("ctrl-shift-") { + (name, KeyModifiers::CONTROL | KeyModifiers::SHIFT) + } else if let Some(name) = key.strip_prefix("ctrl-") { + (name, KeyModifiers::CONTROL) + } else if let Some(name) = key.strip_prefix("shift-") { + (name, KeyModifiers::SHIFT) + } else { + (key, KeyModifiers::NONE) + }; + let unmodified = modifiers.is_empty(); + let code = match name { + "enter" => KeyCode::Enter, + "tab" if modifiers.contains(KeyModifiers::SHIFT) => KeyCode::BackTab, + "tab" => KeyCode::Tab, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + "escape" if unmodified => KeyCode::Esc, + "backtab" if unmodified => KeyCode::BackTab, + "backspace" if unmodified => KeyCode::Backspace, + "delete" if unmodified => KeyCode::Delete, + "space" if unmodified || modifiers.contains(KeyModifiers::CONTROL) => KeyCode::Char(' '), + _ if name.len() == 1 && modifiers.contains(KeyModifiers::CONTROL) => { + let character = name.chars().next()?; + if !character.is_ascii_lowercase() { + return None; + } + KeyCode::Char(character) + } + _ if unmodified => { + let digit = name.strip_prefix("digit-")?; + if digit.len() != 1 || !digit.as_bytes()[0].is_ascii_digit() { + return None; + } + KeyCode::Char(digit.chars().next()?) + } + _ => return None, + }; + Some(KeyEvent::new(code, modifiers)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn terminal_control_keys_are_strict_and_preserve_modifiers() { + assert_eq!( + control_key("ctrl-a").unwrap(), + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL) + ); + assert_eq!( + control_key("ctrl-shift-left").unwrap(), + KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL | KeyModifiers::SHIFT) + ); + assert_eq!(control_key("shift-tab").unwrap().code, KeyCode::BackTab); + for key in [ + "ctrl-A", + "shift-a", + "ctrl-shift-shift-a", + "ctrl-backspace", + "ctrl-escape", + "ctrl-é", + "digit-11", + "raw-bytes", + "\x1b[A", + ] { + assert!(control_key(key).is_none(), "accepted {key:?}"); + } + } + + #[test] + fn terminal_control_key_schemas_match_the_runtime_contract() { + let method: serde_json::Value = serde_json::from_str(include_str!( + "../../../protocol/uhp/v1/terminal/schema/methods/send-key.schema.json" + )) + .unwrap(); + let stream: serde_json::Value = serde_json::from_str(include_str!( + "../../../protocol/uhp/v1/terminal/schema/control-frame.schema.json" + )) + .unwrap(); + let method_keys = method["properties"]["key"]["enum"].as_array().unwrap(); + let stream_keys = stream["oneOf"] + .as_array() + .unwrap() + .iter() + .find(|variant| variant["properties"]["action"]["const"] == "send_key") + .unwrap()["properties"]["params"]["properties"]["key"]["enum"] + .as_array() + .unwrap(); + assert_eq!(method_keys, stream_keys); + let mut expected = std::collections::BTreeSet::new(); + for name in [ + "enter", + "escape", + "tab", + "backtab", + "up", + "down", + "left", + "right", + "home", + "end", + "backspace", + "delete", + "pageup", + "pagedown", + "alt-d", + "space", + ] { + expected.insert(name.to_owned()); + } + for digit in 0..10 { + expected.insert(format!("digit-{digit}")); + } + for prefix in ["ctrl-", "ctrl-shift-"] { + for letter in b'a'..=b'z' { + expected.insert(format!("{prefix}{}", char::from(letter))); + } + expected.insert(format!("{prefix}space")); + } + for prefix in ["ctrl-", "shift-", "ctrl-shift-"] { + for key in [ + "enter", "tab", "up", "down", "left", "right", "home", "end", "pageup", "pagedown", + ] { + expected.insert(format!("{prefix}{key}")); + } + } + let actual: std::collections::BTreeSet<_> = method_keys + .iter() + .map(|value| { + let key = value.as_str().unwrap(); + assert!( + control_key(key).is_some(), + "schema accepts unsupported {key}" + ); + key.to_owned() + }) + .collect(); + assert_eq!(actual.len(), method_keys.len(), "no duplicate schema keys"); + assert_eq!(actual, expected, "all supported keys are documented"); + } +} diff --git a/src/terminal/backend/mod.rs b/src/terminal/backend/mod.rs index 24d1456fc..60413d7b0 100644 --- a/src/terminal/backend/mod.rs +++ b/src/terminal/backend/mod.rs @@ -11,6 +11,9 @@ use serde_json::{json, Value}; use crate::terminal::vt::VtEngine; +mod keys; +pub(crate) use keys::control_key; + pub const PROTOCOL_NAME: &str = "luvus-uhp"; pub const PROTOCOL_MAJOR: u64 = 1; pub const PROTOCOL_MINOR: u64 = 0; @@ -83,7 +86,7 @@ pub fn advertised_capabilities() -> Vec<&'static str> { .collect() } -pub const FEATURES: &[&str] = &["stream_cursor", "create_restore_policy"]; +pub const FEATURES: &[&str] = &["stream_cursor", "create_restore_policy", "modified_keys"]; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CaptureMode { diff --git a/src/terminal/pty.rs b/src/terminal/pty.rs index 0d4a0beec..28ae1c20c 100644 --- a/src/terminal/pty.rs +++ b/src/terminal/pty.rs @@ -1059,16 +1059,6 @@ impl Pane { self.engine.lock().map(|e| e.alt_screen()).unwrap_or(false) } - /// Whether the child enabled application cursor mode (DECCKM). When it has, - /// the key encoder sends SS3 (`ESC O` + letter) cursor-key codes so apps - /// like `less` that are strict about the mode recognize the keys. - pub fn application_cursor(&self) -> bool { - self.engine - .lock() - .map(|e| e.application_cursor()) - .unwrap_or(false) - } - /// Input modes read together under one engine lock. pub fn key_encoding_modes(&self) -> KeyEncodingModes { self.engine diff --git a/src/uhp/gateway.rs b/src/uhp/gateway.rs index 027ec517b..fc33d6242 100644 --- a/src/uhp/gateway.rs +++ b/src/uhp/gateway.rs @@ -844,54 +844,7 @@ fn valid_terminal_control_frame(frame: &str) -> bool { && params .get("key") .and_then(Value::as_str) - .is_some_and(|key| { - matches!( - key, - "enter" - | "escape" - | "tab" - | "backtab" - | "up" - | "down" - | "left" - | "right" - | "home" - | "end" - | "backspace" - | "delete" - | "pageup" - | "pagedown" - | "ctrl-a" - | "ctrl-b" - | "ctrl-c" - | "ctrl-d" - | "ctrl-e" - | "ctrl-f" - | "ctrl-g" - | "ctrl-k" - | "ctrl-l" - | "ctrl-n" - | "ctrl-o" - | "ctrl-p" - | "ctrl-r" - | "ctrl-t" - | "ctrl-u" - | "ctrl-w" - | "ctrl-y" - | "alt-d" - | "space" - | "digit-0" - | "digit-1" - | "digit-2" - | "digit-3" - | "digit-4" - | "digit-5" - | "digit-6" - | "digit-7" - | "digit-8" - | "digit-9" - ) - }) + .is_some_and(|key| crate::terminal::backend::control_key(key).is_some()) } _ => false, } @@ -1268,6 +1221,18 @@ mod tests { assert!(valid_terminal_control_frame( r#"{"id":"key-3","action":"send_key","params":{"key":"alt-d"}}"# )); + for key in [ + "ctrl-a", + "ctrl-z", + "shift-enter", + "ctrl-shift-left", + "ctrl-space", + ] { + assert!(valid_terminal_control_frame( + &serde_json::json!({"id":"key","action":"send_key","params":{"key":key}}) + .to_string() + )); + } assert!(valid_terminal_control_frame( r#"{"id":"key-4","action":"send_key","params":{"key":"ctrl-r"}}"# )); @@ -1287,7 +1252,7 @@ mod tests { r#"{"id":"run-1","action":"pane.run","params":{"text":"id"}}"# )); assert!(!valid_terminal_control_frame( - r#"{"id":"key-1","action":"send_key","params":{"key":"ctrl-z"}}"# + r#"{"id":"key-1","action":"send_key","params":{"key":"raw-bytes"}}"# )); assert!(!valid_terminal_control_frame( r#"{"id":"input-1","action":"submit_text","params":{"text":"ok","extra":true}}"# diff --git a/src/web/assets/app.css b/src/web/assets/app.css index 992eb8f75..d9f95daf4 100644 --- a/src/web/assets/app.css +++ b/src/web/assets/app.css @@ -1 +1 @@ -:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color:#d9d7e7;background:#111219;font-synthesis:none;text-rendering:optimizeLegibility;--bg: #111219;--panel: #191a24;--panel-raised: #20212d;--line: #313241;--muted: #8e8c9f;--accent: #ead778;--accent-ink: #272315;--rally-border: #5a5130;--rally-border-focus: #9a8a50;--green: #9ed68a;--red: #ed8f9e;--status-idle: #94a143;--status-working: #e0a154;--status-done: #b8cf6a;--status-blocked: #cf5a44;--status-unknown: #6e738d;--status-unknown-text: #989db5;--shell-width: 82rem}*{box-sizing:border-box}html,body,#app{min-height:100%;margin:0}body{min-height:100dvh;background-color:var(--bg);background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64' viewBox='0 0 64 64'%3E%3Cpath d='M0 0H64M0 0V64' fill='none' stroke='%23ffffff' stroke-opacity='.035' stroke-width='1'/%3E%3C/svg%3E");background-size:64px 64px}button,textarea,input,select{font:inherit}button{color:inherit}.shell{min-height:100dvh}.web-content-frame{display:contents}.dashboard-header{display:none}.dashboard{position:relative;width:min(var(--shell-width),100%);margin:0 auto;padding:1rem 1rem calc(5rem + env(safe-area-inset-bottom))}.mission-dock{position:sticky;z-index:9;top:.7rem;display:flex;width:max-content;max-width:100%;margin:0 auto 1.1rem;padding:.34rem;gap:.2rem;background:#181920e8;border:1px solid var(--rally-border);border-radius:999px;box-shadow:inset 0 1px #ffffff12,0 1rem 3rem #0005;backdrop-filter:blur(24px) saturate(135%)}.mission-nav-button{display:grid;place-items:center;width:2.8rem;height:2.8rem;padding:0;color:var(--muted);background:transparent;border:0;border-radius:50%;cursor:pointer;transition:color .18s ease,background .18s ease,transform .18s ease}.mission-nav-button:hover,.mission-nav-button:focus-visible{color:#f4f1ff;background:#ffffff0d;outline:none;transform:translateY(-1px)}.mission-nav-button:disabled{color:#6e6c7c;cursor:default;opacity:.55;transform:none}.mission-nav-button.active{color:var(--accent-ink);background:var(--accent);box-shadow:0 0 1.5rem #ead77845}.mission-nav-button.logo.active{background:transparent;border:0;box-shadow:none}.mission-nav-logo{display:block;width:2.55rem;height:2.55rem;filter:drop-shadow(0 0 .65rem #ead77845)}.mission-icon{display:block;width:1.15rem;height:1.15rem}.mission-nav-label{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}.mission-nav-status{display:grid;place-items:center;width:2.8rem;height:2.8rem;color:var(--muted);border-left:1px solid #ffffff12;border-radius:50%}.mission-nav-status.ready{color:var(--green);filter:drop-shadow(0 0 .55rem #9ed68a66)}.mission-nav-status.reconnecting,.mission-nav-status.expired,.mission-nav-status.disconnected{color:var(--red)}.mission-nav-status.connecting{color:var(--accent)}.mission-hero{position:relative;min-height:24rem;padding:clamp(1.4rem,4vw,3.5rem) 0;overflow:visible}.hero-layout{position:relative;z-index:2;display:grid;grid-template-columns:minmax(8rem,1fr) minmax(19rem,1.5fr) minmax(8rem,1fr);align-items:center;gap:clamp(1rem,3vw,3rem)}.hero-center{display:grid;min-width:0;place-items:center}.hero-center>h1{margin:0 0 .35rem;color:#f2eff9;font-size:clamp(1.25rem,2.5vw,1.85rem);font-weight:550;letter-spacing:-.035em}.hero-stat-column{display:grid;gap:2.5rem}.mission-stat{min-width:0;padding-bottom:.9rem;border-bottom:1px solid #ffffff18}.stats-left{text-align:right}.stats-right{text-align:left}.mission-stat>strong{display:block;color:#f4f1ff;font:500 clamp(2rem,4vw,3.4rem)/1 ui-monospace,SFMono-Regular,monospace;letter-spacing:-.1em}.mission-stat span{display:block}.mission-stat>div{margin-top:.28rem}.mission-stat span{color:var(--green);font-size:clamp(.82rem,1.25vw,.96rem);font-weight:600;letter-spacing:.08em;text-transform:uppercase}.hero-session-wrap{position:relative;z-index:4;width:min(18rem,100%);margin-top:.4rem}.hero-session{width:100%;padding:.8rem .7rem 0;color:inherit;background:transparent;border:0;border-top:1px solid #ffffff18;text-align:center;cursor:pointer}.hero-session:hover,.hero-session:focus-visible{border-top-color:#9ed68a66;outline:none}.hero-session span,.hero-session strong{display:block}.hero-session span{color:var(--green);font-size:clamp(.76rem,1.15vw,.88rem);font-weight:600;text-transform:uppercase;letter-spacing:.1em}.hero-session strong{margin-top:.18rem;overflow:hidden;color:#f2eff9;font:500 .95rem ui-monospace,monospace;text-overflow:ellipsis;white-space:nowrap}.session-menu{position:absolute;top:calc(100% + .65rem);left:50%;width:min(18rem,calc(100vw - 2rem));padding:.55rem;color:#f2eff9;background:#181920f2;border:1px solid #ffffff20;border-radius:1rem;box-shadow:inset 0 1px #ffffff12,0 1rem 3rem #0008;backdrop-filter:blur(24px) saturate(135%);transform:translate(-50%)}.session-menu-head{display:flex;align-items:center;justify-content:space-between;gap:.75rem;padding:.35rem .4rem .55rem}.session-menu-head strong{font-size:.75rem;text-transform:uppercase;letter-spacing:.08em}.session-menu-close{padding:.25rem;color:var(--muted);background:transparent;border:0;cursor:pointer;font-size:.65rem}.session-menu-close:hover,.session-menu-close:focus-visible{color:#f2eff9;outline:none}.session-menu-list{display:grid;gap:.25rem;max-height:15rem;overflow:auto}.session-option{display:flex;align-items:center;justify-content:space-between;gap:.75rem;width:100%;padding:.65rem .7rem;color:#dedbe8;background:transparent;border:1px solid transparent;border-radius:.7rem;text-align:left;cursor:pointer}.session-option:hover,.session-option:focus-visible{background:#ffffff0a;border-color:#ffffff12;outline:none}.session-option.active{color:var(--accent);background:#ead7780d;border-color:#ead77826}.session-option:disabled{cursor:default;opacity:.62}.session-option span{overflow:hidden;font:500 .78rem ui-monospace,monospace;text-overflow:ellipsis;white-space:nowrap}.session-option small{flex:none;color:var(--green);font:.55rem ui-monospace,monospace;text-transform:uppercase;letter-spacing:.07em}.session-menu-empty{margin:0;padding:.8rem .7rem;color:var(--muted);font-size:.72rem}.eyebrow{margin:0;color:var(--accent);font-size:.68rem;font-weight:800;letter-spacing:.2em;text-transform:uppercase}.mission-core{position:relative;display:grid;place-items:center;width:min(19rem,100%);aspect-ratio:1;margin:0 auto}.orbit{position:absolute;border:1px solid #ffffff22;border-radius:50%}.orbit:before,.orbit:after{content:"";position:absolute;top:0;left:50%;width:.45rem;height:.45rem;background:var(--accent);border:0;border-radius:50%;transform:translate(-50%,-50%)}.orbit-outer{inset:6%;animation:mission-orbit 18s linear infinite}.orbit-outer:after{top:100%}.orbit-inner{inset:22%;border-style:dashed;animation:mission-orbit 12s linear infinite reverse}.orbit-inner:before,.orbit-inner:after{background:var(--green)}.orbit-inner:after{top:100%}.core-mark{display:grid;place-items:center;width:42%;aspect-ratio:1;border:1px solid #ead77845;border-radius:50%}.core-mark img{width:100%;height:100%}.core-label{position:absolute;right:3%;bottom:13%;padding:.5rem .65rem;background:#15161dc7;border:1px solid #ffffff1c;border-radius:.65rem;backdrop-filter:blur(12px)}.core-label strong,.core-label small{display:block}.core-label strong{color:var(--green);font-size:.58rem;letter-spacing:.12em}.core-label small{max-width:8rem;margin-top:.2rem;overflow:hidden;color:var(--muted);font:.55rem ui-monospace,monospace;text-overflow:ellipsis;white-space:nowrap}@keyframes mission-orbit{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.orbit{animation:none}}.section{margin-top:3rem;scroll-margin-top:8rem}.section-heading{display:flex;align-items:end;justify-content:space-between;margin-bottom:1rem;scroll-margin-top:8rem}.section-title{margin:0;color:#f2eff9;font-size:clamp(1.35rem,3vw,2rem);font-weight:560;letter-spacing:-.035em}.section-status{padding:.38rem .6rem;color:var(--green);background:#9ed68a0b;border:1px solid #9ed68a24;border-radius:999px;font:.58rem ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em}.agent-grid{display:grid;grid-template-columns:1fr;align-items:start;gap:.8rem}.agent-filters{display:flex;gap:.2rem;padding:.2rem;border:1px solid var(--rally-border);border-radius:999px}.agent-filter{padding:.45rem .65rem;border:0;border-radius:999px;background:transparent;color:var(--muted);font-size:.75rem;cursor:pointer}.agent-filter[aria-pressed=true]{background:var(--accent);color:var(--accent-ink)}.agent-filter:focus-visible{outline:2px solid var(--green);outline-offset:2px}.workspace-grid{column-count:1;column-gap:.8rem}.agent-card,.workspace-card{position:relative;background:#1d1e27;border:1px solid #ffffff17;border-radius:1rem;box-shadow:inset 0 1px #ffffff08,0 1rem 3rem #0002}.agent-card{display:flex;align-items:center;gap:.8rem;min-height:5rem;padding:.85rem 1rem;overflow:hidden;text-align:left;cursor:pointer;transition:border-color .18s ease,transform .18s ease,background .18s ease}.agent-card:hover,.agent-card:focus-visible{background:#24252f;border-color:#ead77846;outline:none;transform:translateY(-2px)}.agent-card:disabled{cursor:default;opacity:.65}.agent-card:disabled:hover{background:#1d1e27;border-color:#ffffff17;transform:none}.agent-card strong,.agent-card small{display:block}.agent-card .agent-context{overflow:hidden;color:var(--muted);font-size:.75rem;font-weight:550;text-overflow:ellipsis;white-space:nowrap}.agent-card .agent-session-title{display:-webkit-box;margin-top:.3rem;overflow:hidden;color:#f2eff9;font-size:.95rem;font-weight:600;line-height:1.35;overflow-wrap:anywhere;-webkit-box-orient:vertical;-webkit-line-clamp:2}.agent-card .agent-session-title.absent{color:var(--muted);font-weight:400}.agent-copy{min-width:0;flex:1}.agent-state{padding:.25rem .45rem;color:var(--pane-status-color);background:#6e738d0d;border-radius:999px;font:.55rem ui-monospace,monospace;text-transform:uppercase}.agent-state,.pane-tile{--pane-status-color: var(--status-unknown)}.agent-state.idle,.pane-tile.idle{--pane-status-color: var(--status-idle)}.agent-state.working,.pane-tile.working{--pane-status-color: var(--status-working)}.agent-state.done,.pane-tile.done{--pane-status-color: var(--status-done)}.agent-state.blocked,.pane-tile.blocked{--pane-status-color: var(--status-blocked)}.agent-state.idle{background:#94a1430d}.agent-state.working{background:#e0a1540d}.agent-state.done{background:#b8cf6a0d}.agent-state.blocked{background:#cf5a440d}.agent-card>.mission-icon{width:1rem;height:1rem;color:var(--muted);transition:transform .18s ease}.agent-card:hover>.mission-icon{color:var(--accent);transform:translate(2px)}.workspace-card{width:100%;margin:0 0 .8rem;padding:0;overflow:hidden;break-inside:avoid}.workspace-head{display:flex;align-items:start;justify-content:space-between;gap:.8rem;padding:1rem}.workspace-title{min-width:0}.workspace-name-row{display:flex;min-width:0;align-items:center;gap:.5rem}.workspace-presence{width:.48rem;height:.48rem;flex:none;background:#6e6c7c;border-radius:50%}.workspace-presence.active{background:var(--green);box-shadow:0 0 .7rem #9ed68a55}.workspace-name-row h3{min-width:0;margin:0;overflow:hidden;color:#f2eff9;font-size:1.02rem;font-weight:600;letter-spacing:-.02em;text-overflow:ellipsis;white-space:nowrap}.workspace-branch{min-width:0;padding:.18rem .38rem;overflow:hidden;color:var(--accent);background:#dbc66f0b;border:1px solid #dbc66f1c;border-radius:999px;font:.52rem ui-monospace,monospace;text-overflow:ellipsis;white-space:nowrap}.workspace-title>p{margin:.38rem 0 0 .98rem;overflow:hidden;color:var(--muted);font:.65rem ui-monospace,monospace;text-overflow:ellipsis;white-space:nowrap}.workspace-tab-total{flex:none;padding:.28rem .46rem;color:#b8b5c5;background:#ffffff08;border-radius:999px;font:.56rem ui-monospace,monospace;white-space:nowrap}.workspace-tabs{border-top:1px solid #ffffff12}.workspace-tab{padding:.78rem 1rem .9rem;border-bottom:1px solid #ffffff0e}.workspace-tab:last-child{border-bottom:0}.workspace-tab-head{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.5rem}.tab-presence{width:.42rem;height:.42rem;border:1px solid #777587;border-radius:50%}.workspace-tab.active .tab-presence{background:var(--accent);border-color:var(--accent);box-shadow:0 0 .6rem #dbc66f45}.workspace-tab-head strong{overflow:hidden;color:#dedbe8;font-size:.75rem;font-weight:570;text-overflow:ellipsis;white-space:nowrap}.tab-pane-total{min-width:1.35rem;padding:.14rem .3rem;color:var(--muted);background:#ffffff08;border-radius:999px;font:.52rem ui-monospace,monospace;text-align:center}.pane-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(7.25rem,1fr));gap:.4rem;margin-top:.65rem}.pane-tile{display:flex;min-width:0;align-items:center;gap:.5rem;padding:.52rem .58rem;color:#d9d7e7;background:#111219a6;border:1px solid #ffffff12;border-radius:.65rem;text-align:left}button.pane-tile{cursor:pointer;transition:background .16s ease,border-color .16s ease,transform .16s ease}button.pane-tile:hover,button.pane-tile:focus-visible{background:#252631;border-color:#ffffff24;outline:none;transform:translateY(-1px)}.pane-tile.focused{border-color:#dbc66f59;box-shadow:inset 0 0 0 1px #dbc66f18}.pane-presence{width:.42rem;height:.42rem;flex:none;background:var(--pane-status-color);border-radius:50%}.pane-tile.working .pane-presence{box-shadow:0 0 .55rem #e0a15455}.pane-copy{min-width:0;flex:1}.pane-copy strong,.pane-copy small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pane-copy strong{font-size:.68rem;font-weight:570}.pane-copy small{margin-top:.16rem;color:var(--pane-status-color);font:.52rem ui-monospace,monospace;text-transform:uppercase;letter-spacing:.04em}.agent-state.terminal,.pane-tile.terminal .pane-copy small,.pane-tile.view .pane-copy small{color:var(--status-unknown-text)}.pane-tile>.mission-icon{width:.8rem;height:.8rem;flex:none;color:#777587}.pane-tile.view{color:var(--muted)}.workspace-empty{margin:0;padding:1rem;color:var(--muted);font-size:.7rem;text-align:center}.ghost,.primary,.key{min-height:2.5rem;padding:.5rem .8rem;border:1px solid var(--line);border-radius:.65rem;cursor:pointer}.ghost,.key{background:var(--panel)}.primary{color:var(--accent-ink);background:var(--accent);border-color:var(--accent);font-weight:800}button:disabled{cursor:not-allowed;opacity:.45}.empty-state{display:grid;place-items:center;align-content:center;min-height:calc(100dvh - 8rem);padding:2rem;text-align:center}.empty-state h1{margin:1rem 0 .4rem}.empty-state p{max-width:28rem;margin:0 0 1.3rem;color:var(--muted)}.pair-form{display:flex;gap:.5rem;width:min(28rem,100%)}.pair-input{flex:1;min-width:0;padding:.65rem .7rem;color:#d9d7e7;background:var(--bg);border:1px solid var(--line);border-radius:.6rem;font:.8rem ui-monospace,monospace}.pair-input:focus-visible{outline:2px solid var(--accent);outline-offset:1px}.pair-error{margin:.8rem 0 0;color:#f0a0a0}.pulse{width:1rem;height:1rem;background:var(--accent);border-radius:50%;box-shadow:0 0 #ead77866;animation:pulse 1.4s infinite}@keyframes pulse{70%{box-shadow:0 0 0 1rem #ead77800}}.toast{position:fixed;z-index:30;right:1rem;bottom:calc(1rem + env(safe-area-inset-bottom));left:1rem;padding:.9rem 1rem;color:#ffe4e8;background:#4b252d;border:1px solid #ed8f9e66;border-radius:.8rem;box-shadow:0 1rem 3rem #0007}.toast.success{color:#e6ffdf;background:#243b27;border-color:#9ed68a66}.device-overlay{position:fixed;z-index:25;inset:0;display:grid;place-items:end center;padding:1rem;background:#090a10aa;backdrop-filter:blur(10px)}.device-panel{width:min(32rem,100%);padding:1.2rem;background:var(--panel-raised);border:1px solid var(--line);border-radius:1.25rem;box-shadow:0 1.5rem 5rem #000a}.device-panel-head{display:flex;align-items:start;justify-content:space-between;gap:1rem}.device-panel h2{margin:.25rem 0 0;font-size:1.25rem}.device-close{padding:.35rem .45rem;color:var(--muted);background:transparent;border:0;cursor:pointer}.device-close:hover,.device-close:focus-visible{color:#f4f1ff;outline:none}.device-copy{margin:1rem 0;color:var(--muted)}.device-limit-row{display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.75rem 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.device-limit{min-width:4rem;padding:.45rem .6rem;color:inherit;background:var(--bg);border:1px solid var(--line);border-radius:.6rem}.device-url-setting{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:.65rem;padding-top:.85rem}.device-url-label{display:grid;gap:.4rem;min-width:0;color:#e2dfeb;font-size:.82rem}.device-url-input{width:100%;min-width:0;padding:.65rem .7rem;color:#e7e4ef;background:var(--bg);border:1px solid var(--line);border-radius:.65rem;font:.75rem ui-monospace,monospace}.device-url-input:focus{border-color:var(--rally-border-focus);outline:none;box-shadow:0 0 0 2px #dbc66f14}.device-url-save{min-height:2.35rem;white-space:nowrap}.device-help{margin:.65rem 0 0;color:var(--muted);font-size:.78rem;line-height:1.45}.device-help+.device-help{margin-bottom:1rem}.device-pair{width:100%}.pairing-card{padding:.9rem;background:#11121988;border:1px solid #ead77855;border-radius:.8rem}.pairing-card strong,.pairing-card p{display:block}.pairing-card p{margin:.35rem 0 .7rem;color:var(--muted);font-size:.82rem}.pairing-qr-wrap{display:grid;place-items:center;margin:.8rem 0}.pairing-qr{display:block;width:min(13.75rem,70vw);height:auto;padding:.4rem;background:#fff;border-radius:.75rem;image-rendering:pixelated}.pairing-link{width:100%;padding:.65rem .7rem;color:#d9d7e7;background:var(--bg);border:1px solid var(--line);border-radius:.6rem;font:.75rem ui-monospace,monospace}.pairing-actions{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.7rem}@media(max-width:34rem){.device-url-setting{grid-template-columns:1fr}.device-url-save{width:100%}}.terminal-screen{--terminal-circle-size: 3.125rem;--terminal-header-top: max(.5rem, env(safe-area-inset-top));position:fixed;top:var(--terminal-viewport-top, 0);right:0;left:0;display:grid;grid-template-rows:minmax(0,1fr);width:100%;max-width:100vw;height:var(--terminal-viewport-height, 100dvh);overflow:hidden;background:#0d0e14}.terminal-screen:before{content:"";position:absolute;z-index:0;inset:0;pointer-events:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64' viewBox='0 0 64 64'%3E%3Cpath d='M0 0H64M0 0V64' fill='none' stroke='%23ffffff' stroke-opacity='.045' stroke-width='1'/%3E%3C/svg%3E");background-size:64px 64px}.terminal-main{position:relative;z-index:1;grid-column:1;grid-row:1;display:grid;grid-template-rows:minmax(0,1fr);min-width:0;min-height:0}.terminal-sidebar,.terminal-workspace-sidebar,.terminal-navigation,.terminal-navigation-header{display:none}.terminal-header{position:absolute;z-index:5;top:var(--terminal-header-top);left:50%;display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:.65rem;width:min(62rem,calc(100% - 2rem));margin:0;padding:0;transform:translate(-50%)}.terminal-header .terminal-workspace-brand{width:max-content;margin:0}.terminal-pane-switcher{position:relative;justify-self:end;width:max-content;max-width:100%;min-width:0}.terminal-pane-selector{display:grid;place-items:center;width:var(--terminal-circle-size);height:var(--terminal-circle-size);padding:0;color:#d9d7e7;background:#1b1c24b8;border:1px solid var(--rally-border);border-radius:50%;cursor:pointer;box-shadow:inset 0 1px #ffffff12,0 .75rem 2rem #0003;-webkit-backdrop-filter:blur(24px) saturate(150%);backdrop-filter:blur(24px) saturate(150%)}.terminal-pane-selector:hover,.terminal-pane-selector:focus-visible,.terminal-pane-selector[aria-expanded=true]{background:#22232cdd;border-color:var(--rally-border-focus);outline:none}.terminal-pane-dot{width:.6rem;height:.6rem;background:currentColor;border-radius:50%}.terminal-pane-selector[aria-expanded=true]{color:var(--accent)}.terminal-sidebar-heading{display:flex;align-items:center;justify-content:space-between;gap:.5rem;margin-bottom:.6rem;padding-inline:.2rem}.terminal-sidebar-heading h2{margin:0;color:#ece9f3;font-size:.9rem;font-weight:600;letter-spacing:-.02em}.terminal-sidebar-count{padding:.15rem .4rem;color:var(--muted);background:#ffffff06;border-radius:.4rem;font:.65rem ui-monospace,monospace}.terminal-sidebar-heading>.terminal-sidebar-count{margin-left:auto}.sidebar-collapse-toggle{display:none;flex:none;place-items:center;width:2rem;height:2rem;padding:0;color:var(--muted);background:transparent;border:0;border-radius:.4rem;cursor:pointer}.sidebar-collapse-toggle:hover{color:var(--accent);background:#ffffff06}.sidebar-collapse-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}.sidebar-collapse-toggle[aria-expanded=false] .sidebar-collapse-arrow{transform:rotate(180deg)}.terminal-sidebar-toolbar{display:flex;flex:none;align-items:center;gap:.75rem;margin-bottom:.5rem}.terminal-sidebar-filters{flex:1;min-width:0}.terminal-sidebar-filters .agent-filter{flex:1;padding:.35rem .4rem;font-size:.7rem;white-space:nowrap}.terminal-sidebar-close{display:none;flex:none}.terminal-sidebar-list{flex:1;min-height:0;padding:.2rem;overflow-y:auto;overscroll-behavior:contain;overflow-anchor:none;scrollbar-width:thin;scrollbar-color:#383944 transparent}.terminal-sidebar-pane{display:block;width:100%;min-height:44px;margin-bottom:.1rem;padding:.4rem .5rem;color:#dedbe8;background:transparent;border:0;border-radius:.5rem;text-align:left;cursor:pointer;transition:background .15s ease}.terminal-sidebar-pane:hover{background:#ffffff06}.terminal-sidebar-pane:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}.terminal-sidebar-pane.active{background:#ead7780b}.terminal-sidebar-pane{--pane-status-color: var(--status-unknown)}.terminal-sidebar-pane.idle{--pane-status-color: var(--status-idle)}.terminal-sidebar-pane.working{--pane-status-color: var(--status-working)}.terminal-sidebar-pane.done{--pane-status-color: var(--status-done)}.terminal-sidebar-pane.blocked{--pane-status-color: var(--status-blocked)}.terminal-sidebar-dot{width:.42rem;height:.42rem;flex:none;background:var(--pane-status-color);border:1px solid var(--pane-status-color);border-radius:50%}.terminal-sidebar-pane.idle .terminal-sidebar-dot,.terminal-sidebar-pane.terminal .terminal-sidebar-dot{background:transparent}.terminal-sidebar-copy{display:block;min-width:0}.terminal-sidebar-label{display:flex;align-items:center;gap:.25rem;min-width:0;font-size:.8rem;font-weight:600;line-height:1.3;white-space:nowrap}.terminal-sidebar-state{flex:none;color:var(--pane-status-color);font:500 .72rem ui-monospace,monospace}.terminal-sidebar-pane.terminal .terminal-sidebar-state{color:var(--status-unknown-text)}.terminal-sidebar-agent{min-width:0;overflow:hidden;color:#aaa7b7;font-size:.8rem;font-weight:500;text-overflow:ellipsis}.terminal-sidebar-pane.active .terminal-sidebar-agent{color:var(--accent)}.terminal-sidebar-detail{display:flex;gap:.25rem;min-width:0;margin-top:.1rem;padding-left:.67rem;color:var(--muted);font-size:.675rem;line-height:1.3;white-space:nowrap}.terminal-sidebar-title{min-width:0;overflow:hidden;text-overflow:ellipsis}.terminal-sidebar-context{flex:none;max-width:50%;overflow:hidden;text-overflow:ellipsis}.terminal-sidebar-title[hidden]+.terminal-sidebar-context{max-width:100%}.terminal-sidebar-empty{margin:1.5rem .5rem;color:var(--muted);font-size:.8rem;line-height:1.6}.terminal-workspace-brand{display:flex;flex:none;align-items:center;gap:.55rem;width:100%;margin-bottom:1.2rem;padding:.25rem .4rem;color:var(--accent);background:transparent;border:0;border-radius:.4rem;font-size:1.1rem;font-weight:650;text-align:left;cursor:pointer}.terminal-workspace-brand img{display:block;width:2.1rem;height:2.1rem}.terminal-workspace-brand:hover{background:#ffffff06}.terminal-workspace-brand:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}.terminal-workspace-topbar{display:flex;flex:none;align-items:center;gap:.4rem;margin-bottom:1.2rem}.terminal-workspace-topbar .terminal-workspace-brand{flex:1;min-width:0;width:auto;margin:0}.terminal-workspace-footer{flex:none;padding:.9rem .2rem 0}.terminal-workspace-footer .mission-dock{position:static;margin:0;padding:0;background:transparent;border:0;border-radius:0;box-shadow:none;backdrop-filter:none}.terminal-workspace-footer .mission-nav-status{border:0;border-radius:0}.terminal-workspace-group{margin-bottom:.25rem}.terminal-workspace-label{display:flex;align-items:center;gap:.4rem;width:100%;min-width:0;min-height:2.1rem;padding:.35rem .4rem;background:transparent;border:0;border-radius:.4rem;cursor:pointer;text-align:left}.terminal-workspace-label:hover{background:#ffffff06}.terminal-workspace-label:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}.terminal-workspace-name{min-width:0;overflow:hidden;color:#dedbe8;font-size:.8rem;font-weight:600;text-overflow:ellipsis;white-space:nowrap}.terminal-workspace-copy{display:block;width:100%;min-width:0}.terminal-workspace-line{display:flex;align-items:center;gap:.4rem;min-width:0}.terminal-workspace-branch{flex:0 1 auto;min-width:0;max-width:45%;overflow:hidden;color:var(--muted);font:.65rem ui-monospace,monospace;text-overflow:ellipsis;white-space:nowrap}.terminal-workspace-branch[hidden]{display:none}.terminal-workspace-group.current .terminal-workspace-branch{color:var(--green)}.terminal-workspace-path{display:block;min-width:0;margin-top:.2rem;padding-left:.8rem;overflow:hidden;color:var(--muted);font:.65rem/1.4 ui-monospace,monospace;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left;unicode-bidi:plaintext}.terminal-workspace-dot{width:.4rem;height:.4rem;flex:none;border:1px solid var(--muted);border-radius:50%}.terminal-workspace-group.current .terminal-workspace-dot{background:var(--green);border-color:var(--green)}.terminal-output{position:relative;z-index:1;grid-row:1;width:100%;max-width:64rem;min-width:0;min-height:0;margin:0 auto;padding:5rem 1rem 1.1rem;overflow:auto;overflow-x:hidden;color:#d9d7e7;background:#0d0e14;outline:none;overscroll-behavior:contain;overflow-anchor:none;scrollbar-width:none;white-space:pre-wrap;overflow-wrap:anywhere;word-break:normal;tab-size:4;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,Symbols Nerd Font Mono,Symbols Nerd Font,MesloLGS NF,MesloLGS Nerd Font Mono,JetBrainsMono Nerd Font Mono,JetBrainsMono Nerd Font,FiraCode Nerd Font Mono,Hack Nerd Font Mono,monospace;font-size:.9rem;line-height:1.5}.terminal-cell-probe{position:absolute;visibility:hidden;white-space:pre;pointer-events:none}.terminal-output:focus-visible{box-shadow:inset 0 0 0 1px #ead77855}.terminal-output::-webkit-scrollbar{display:none}.terminal-status{margin-top:1rem;padding:.65rem .8rem;color:#ffe4e8;background:#4b252d;border-radius:.65rem}.terminal-caret{display:inline-block;position:relative;width:0;height:1em;vertical-align:-.14em}.terminal-caret:after{content:"";position:absolute;bottom:-.04em;left:0;width:.62em;height:1.12em;background:var(--accent);border-radius:.08em;animation:terminal-caret-blink 1.05s steps(1,end) infinite}@keyframes terminal-caret-blink{50%{opacity:0}}@media(prefers-reduced-motion:reduce){.terminal-caret:after{animation:none}}.terminal-controls-wrap{position:absolute;z-index:6;bottom:max(.75rem,env(safe-area-inset-bottom));left:50%;display:flex;justify-content:flex-end;width:min(64rem,100%);padding-right:max(1rem,env(safe-area-inset-right));background:transparent;pointer-events:none;transform:translate(-50%)}.terminal-controls{display:flex;align-items:center;justify-content:flex-end;width:max-content;min-width:var(--terminal-circle-size);max-width:100%;min-height:var(--terminal-circle-size);margin:0;padding:.22rem;background:#1b1c24b8;border:1px solid var(--rally-border);border-radius:999px;box-shadow:inset 0 1px #ffffff12,0 .7rem 1.8rem #0005;-webkit-backdrop-filter:blur(24px) saturate(150%);backdrop-filter:blur(24px) saturate(150%);pointer-events:auto}.keyboard-active .terminal-controls{border-color:var(--rally-border-focus)}.terminal-input-hint{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}.terminal-tools{display:flex;flex:0 1 auto;align-items:center;gap:.08rem;min-width:0;max-width:0;overflow:hidden;opacity:0;pointer-events:none;transform:translate(1rem);transition:max-width .24s ease,opacity .16s ease,transform .24s ease}.controls-expanded .terminal-tools{max-width:min(32rem,calc(100vw - 5rem));overflow-x:auto;opacity:1;pointer-events:auto;transform:translate(0)}.terminal-tools::-webkit-scrollbar{display:none}.terminal-controls-toggle{display:grid;flex:0 0 auto;place-items:center;width:calc(var(--terminal-circle-size) - .44rem - 2px);height:calc(var(--terminal-circle-size) - .44rem - 2px);padding:0;color:var(--accent);background:#24252e;border:0;border-radius:50%;cursor:pointer}.terminal-controls-toggle:before{content:"";width:.32rem;height:.32rem;background:currentColor;box-shadow:.46rem 0 currentColor,0 .46rem currentColor,.46rem .46rem currentColor;transform:translate(-.23rem,-.23rem)}.terminal-controls-toggle:hover,.terminal-controls-toggle:focus-visible{color:#fff7cf;background:#30313b;outline:none}.controls-expanded .terminal-controls-toggle{color:#d9d7e7}.controls-expanded .terminal-controls-toggle:before{content:"×";width:auto;height:auto;background:transparent;box-shadow:none;font:400 1.35rem/1 ui-monospace,monospace;transform:none}.terminal-tool{display:grid;flex:0 0 auto;place-items:center;min-width:2.1rem;height:2.1rem;padding:0 .5rem;color:#c2c0cc;background:transparent;border:0;border-radius:999px;font:.68rem ui-monospace,monospace;cursor:pointer}.terminal-tool:hover,.terminal-tool:focus-visible{color:#f4f1ff;background:#ffffff0d;outline:none}.terminal-tool:active{color:var(--accent-ink);background:var(--accent)}.terminal-tool:disabled{cursor:not-allowed;opacity:.38}.keyboard-toggle{color:var(--accent);font-family:inherit;font-size:.9rem}.attach-file{color:var(--accent);font-family:inherit;font-size:1.2rem;font-weight:600}.terminal-file-input{position:fixed;width:1px;height:1px;opacity:0;pointer-events:none}.terminal-screen.file-drag-active:after{content:"Drop files to attach";position:fixed;z-index:20;inset:1rem;display:grid;place-items:center;color:var(--accent);background:#111219dd;border:2px dashed #ead77888;border-radius:1.5rem;font-weight:800;letter-spacing:.04em;pointer-events:none}.terminal-input-proxy{position:fixed;right:0;bottom:0;width:1px;height:1px;padding:0;overflow:hidden;opacity:0;border:0;outline:0;resize:none;pointer-events:none}@media(min-width:1024px){.terminal-screen,.dashboard-layout{--workspace-sidebar-width: clamp(11rem, 14vw, 14rem);--pane-sidebar-width: clamp(12rem, 16vw, 16rem);display:grid;grid-template-columns:var(--workspace-sidebar-width) minmax(0,1fr) var(--pane-sidebar-width);grid-template-rows:minmax(0,1fr);gap:0;padding:.75rem;background:var(--bg)}.terminal-screen.workspaces-collapsed,.dashboard-layout.workspaces-collapsed{--workspace-sidebar-width: 3.5rem}.terminal-screen.panes-collapsed,.dashboard-layout.panes-collapsed{--pane-sidebar-width: 3.5rem}.dashboard-layout{height:100dvh;min-height:0;overflow:hidden}.web-content-frame{position:relative;z-index:1;grid-column:2;grid-row:1;display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr);min-width:0;min-height:0;overflow:hidden;background:#13141c;border:1px solid #ffffff14;border-radius:1rem}.dashboard-layout .dashboard{grid-column:1;grid-row:1;display:grid;align-content:safe center;width:100%;min-width:0;min-height:0;padding:2rem 1.5rem;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:#383944 transparent}.dashboard-layout .hero-layout{grid-template-columns:minmax(5rem,1fr) minmax(14rem,1.4fr) minmax(5rem,1fr)}.terminal-main{grid-column:1;grid-row:1}.terminal-navigation{display:contents}.dashboard-header,.terminal-header{display:none}.terminal-output{max-width:none;padding-top:1rem}.terminal-pane-switcher{display:none}.terminal-sidebar,.terminal-workspace-sidebar{z-index:2;display:flex;flex-direction:column;min-width:0;min-height:0;padding:.9rem .65rem .7rem;background:#111219}.terminal-sidebar{grid-column:3;grid-row:1;padding:0 0 .2rem .75rem}.terminal-workspace-sidebar{grid-column:1;grid-row:1;padding:0 .75rem .2rem 0}.terminal-workspace-topbar,.terminal-sidebar>.terminal-sidebar-heading{flex:none;min-height:3rem;margin-bottom:.5rem;padding-inline:.2rem}.sidebar-collapse-toggle{display:grid}.terminal-sidebar.collapsed,.terminal-workspace-sidebar.collapsed{padding-inline:.35rem}.terminal-sidebar.collapsed .terminal-sidebar-heading{justify-content:center}.collapsed .terminal-sidebar-heading h2,.collapsed .terminal-sidebar-count,.collapsed .terminal-sidebar-list,.collapsed .terminal-sidebar-toolbar{display:none}.terminal-workspace-sidebar.collapsed .terminal-workspace-topbar{flex-direction:column;gap:0;min-height:calc(4.25rem + 1px)}.terminal-workspace-sidebar.collapsed .terminal-workspace-brand{justify-content:center;padding:0}.terminal-workspace-sidebar.collapsed .terminal-workspace-brand span{display:none}.terminal-workspace-sidebar.collapsed .terminal-workspace-footer{margin-top:auto;padding-inline:0}.terminal-workspace-sidebar.collapsed .mission-dock{flex-direction:column;gap:0}.terminal-sidebar-pane{min-height:0;padding:.3rem .4rem}}@media not all and (min-width:1024px){.dashboard-layout{display:flex;flex-direction:column;min-height:100dvh}.dashboard-header{display:flex;flex:none;align-items:center;justify-content:space-between;gap:.75rem;padding:max(.5rem,env(safe-area-inset-top)) max(1rem,env(safe-area-inset-right)) .5rem max(1rem,env(safe-area-inset-left))}.dashboard-header .terminal-workspace-brand{width:max-content;margin:0}.dashboard-layout .dashboard{display:grid;flex:1;align-content:center;min-width:0;padding:1rem 1rem max(1rem,env(safe-area-inset-bottom))}.dashboard-header .terminal-pane-selector{width:3.125rem;height:3.125rem}.dashboard-layout.pane-navigation-open .terminal-navigation{position:fixed}.pane-navigation-open .terminal-navigation{position:absolute;z-index:10;inset:0;display:flex;flex-direction:column;min-width:0;min-height:0;padding:max(.5rem,env(safe-area-inset-top)) max(1rem,env(safe-area-inset-right)) calc(3.5rem + env(safe-area-inset-bottom)) max(1rem,env(safe-area-inset-left));background:var(--bg)}.pane-navigation-open .terminal-navigation-header{display:flex;flex:none;align-items:center;justify-content:space-between;gap:.75rem;margin-bottom:.75rem}.terminal-navigation-header .terminal-workspace-brand{width:max-content;margin:0}.pane-navigation-open .terminal-workspace-sidebar{display:flex;flex:0 1 auto;flex-direction:column;min-height:0;max-height:30%;margin-bottom:.75rem;padding-bottom:.75rem;border-bottom:1px solid #ffffff0d}.pane-navigation-open .terminal-sidebar{display:flex;flex:1;flex-direction:column;min-height:0}.terminal-navigation .terminal-workspace-topbar{display:none}.terminal-navigation .terminal-workspace-footer{position:absolute;bottom:max(.5rem,env(safe-area-inset-bottom));left:max(1rem,env(safe-area-inset-left));padding:0}.terminal-navigation .terminal-workspace-label{min-height:44px}.terminal-sidebar-toolbar{margin-bottom:.5rem}.terminal-sidebar-close{display:grid}.terminal-sidebar-list{padding:0}.terminal-sidebar-pane{border-radius:0}}@media(max-width:800px){.hero-layout{grid-template-areas:"center center" "left right";grid-template-columns:repeat(2,minmax(0,1fr))}.hero-center{grid-area:center}.stats-left{grid-area:left}.stats-right{grid-area:right}.mission-core{width:min(17rem,70vw)}}@media(max-width:600px){.dashboard{padding-inline:.7rem}.mission-dock{top:.45rem;margin-bottom:.7rem}.mission-nav-button{width:2.65rem;height:2.65rem}.mission-nav-logo{width:2.35rem;height:2.35rem}.mission-nav-status{width:2.65rem;height:2.65rem}.mission-hero{min-height:0;padding:1.25rem 0}.hero-layout{gap:1.4rem .9rem}.hero-stat-column{gap:1.2rem}.mission-stat{min-width:0;padding:0 0 .65rem}.mission-stat>strong{font-size:1.9rem}.mission-stat>div{margin-top:.22rem}.mission-stat span{font-size:.76rem}.mission-core{position:relative;right:auto;bottom:auto;width:min(14rem,68vw);opacity:1}.core-label{display:block}.hero-session-wrap{width:min(16rem,100%)}.section{margin-top:2.3rem}.section-heading{align-items:center}.section-status{max-width:9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.workspace-head{padding:.9rem}.workspace-tab{padding-inline:.9rem}.pane-grid{grid-template-columns:repeat(auto-fit,minmax(6.5rem,1fr))}.terminal-screen:before{display:none}.terminal-header{width:calc(100% - 1.8rem);gap:.45rem}.terminal-output{padding:4.8rem .8rem 1.25rem;font-size:.9rem;line-height:1.6}.terminal-controls-wrap{bottom:max(.55rem,env(safe-area-inset-bottom));padding-right:max(.9rem,env(safe-area-inset-right))}.terminal-tools{gap:.05rem}.terminal-tool{min-width:2.35rem;height:2.35rem;padding-inline:.5rem;font-size:.82rem}.keyboard-toggle{font-size:1.05rem}.attach-file{font-size:1.25rem}}@media(min-width:720px){.dashboard{padding:2rem}.agent-grid{grid-template-columns:repeat(3,1fr)}.workspace-grid{column-count:2}.toast{right:2rem;left:auto;width:min(28rem,calc(100vw - 4rem))}.device-overlay{place-items:center}}@media(min-width:1100px){.workspace-grid{column-count:3}} +:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color:#d9d7e7;background:#111219;font-synthesis:none;text-rendering:optimizeLegibility;--bg: #111219;--panel: #191a24;--panel-raised: #20212d;--line: #313241;--muted: #8e8c9f;--accent: #ead778;--accent-ink: #272315;--rally-border: #5a5130;--rally-border-focus: #9a8a50;--green: #9ed68a;--red: #ed8f9e;--status-idle: #94a143;--status-working: #e0a154;--status-done: #b8cf6a;--status-blocked: #cf5a44;--status-unknown: #6e738d;--status-unknown-text: #989db5;--shell-width: 82rem}*{box-sizing:border-box}html,body,#app{min-height:100%;margin:0}body{min-height:100dvh;background-color:var(--bg);background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64' viewBox='0 0 64 64'%3E%3Cpath d='M0 0H64M0 0V64' fill='none' stroke='%23ffffff' stroke-opacity='.035' stroke-width='1'/%3E%3C/svg%3E");background-size:64px 64px}button,textarea,input,select{font:inherit}button{color:inherit}.shell{min-height:100dvh}.web-content-frame{display:contents}.dashboard-header{display:none}.dashboard{position:relative;width:min(var(--shell-width),100%);margin:0 auto;padding:1rem 1rem calc(5rem + env(safe-area-inset-bottom))}.mission-dock{position:sticky;z-index:9;top:.7rem;display:flex;width:max-content;max-width:100%;margin:0 auto 1.1rem;padding:.34rem;gap:.2rem;background:#181920e8;border:1px solid var(--rally-border);border-radius:999px;box-shadow:inset 0 1px #ffffff12,0 1rem 3rem #0005;backdrop-filter:blur(24px) saturate(135%)}.mission-nav-button{display:grid;place-items:center;width:2.8rem;height:2.8rem;padding:0;color:var(--muted);background:transparent;border:0;border-radius:50%;cursor:pointer;transition:color .18s ease,background .18s ease,transform .18s ease}.mission-nav-button:hover,.mission-nav-button:focus-visible{color:#f4f1ff;background:#ffffff0d;outline:none;transform:translateY(-1px)}.mission-nav-button:disabled{color:#6e6c7c;cursor:default;opacity:.55;transform:none}.mission-nav-button.active{color:var(--accent-ink);background:var(--accent);box-shadow:0 0 1.5rem #ead77845}.mission-nav-button.logo.active{background:transparent;border:0;box-shadow:none}.mission-nav-logo{display:block;width:2.55rem;height:2.55rem;filter:drop-shadow(0 0 .65rem #ead77845)}.mission-icon{display:block;width:1.15rem;height:1.15rem}.mission-nav-label{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}.mission-nav-status{display:grid;place-items:center;width:2.8rem;height:2.8rem;color:var(--muted);border-left:1px solid #ffffff12;border-radius:50%}.mission-nav-status.ready{color:var(--green);filter:drop-shadow(0 0 .55rem #9ed68a66)}.mission-nav-status.reconnecting,.mission-nav-status.expired,.mission-nav-status.disconnected{color:var(--red)}.mission-nav-status.connecting{color:var(--accent)}.mission-hero{position:relative;min-height:24rem;padding:clamp(1.4rem,4vw,3.5rem) 0;overflow:visible}.hero-layout{position:relative;z-index:2;display:grid;grid-template-columns:minmax(8rem,1fr) minmax(19rem,1.5fr) minmax(8rem,1fr);align-items:center;gap:clamp(1rem,3vw,3rem)}.hero-center{display:grid;min-width:0;place-items:center}.hero-center>h1{margin:0 0 .35rem;color:#f2eff9;font-size:clamp(1.25rem,2.5vw,1.85rem);font-weight:550;letter-spacing:-.035em}.hero-stat-column{display:grid;gap:2.5rem}.mission-stat{min-width:0;padding-bottom:.9rem;border-bottom:1px solid #ffffff18}.stats-left{text-align:right}.stats-right{text-align:left}.mission-stat>strong{display:block;color:#f4f1ff;font:500 clamp(2rem,4vw,3.4rem)/1 ui-monospace,SFMono-Regular,monospace;letter-spacing:-.1em}.mission-stat span{display:block}.mission-stat>div{margin-top:.28rem}.mission-stat span{color:var(--green);font-size:clamp(.82rem,1.25vw,.96rem);font-weight:600;letter-spacing:.08em;text-transform:uppercase}.hero-session-wrap{position:relative;z-index:4;width:min(18rem,100%);margin-top:.4rem}.hero-session{width:100%;padding:.8rem .7rem 0;color:inherit;background:transparent;border:0;border-top:1px solid #ffffff18;text-align:center;cursor:pointer}.hero-session:hover,.hero-session:focus-visible{border-top-color:#9ed68a66;outline:none}.hero-session span,.hero-session strong{display:block}.hero-session span{color:var(--green);font-size:clamp(.76rem,1.15vw,.88rem);font-weight:600;text-transform:uppercase;letter-spacing:.1em}.hero-session strong{margin-top:.18rem;overflow:hidden;color:#f2eff9;font:500 .95rem ui-monospace,monospace;text-overflow:ellipsis;white-space:nowrap}.session-menu{position:absolute;top:calc(100% + .65rem);left:50%;width:min(18rem,calc(100vw - 2rem));padding:.55rem;color:#f2eff9;background:#181920f2;border:1px solid #ffffff20;border-radius:1rem;box-shadow:inset 0 1px #ffffff12,0 1rem 3rem #0008;backdrop-filter:blur(24px) saturate(135%);transform:translate(-50%)}.session-menu-head{display:flex;align-items:center;justify-content:space-between;gap:.75rem;padding:.35rem .4rem .55rem}.session-menu-head strong{font-size:.75rem;text-transform:uppercase;letter-spacing:.08em}.session-menu-close{padding:.25rem;color:var(--muted);background:transparent;border:0;cursor:pointer;font-size:.65rem}.session-menu-close:hover,.session-menu-close:focus-visible{color:#f2eff9;outline:none}.session-menu-list{display:grid;gap:.25rem;max-height:15rem;overflow:auto}.session-option{display:flex;align-items:center;justify-content:space-between;gap:.75rem;width:100%;padding:.65rem .7rem;color:#dedbe8;background:transparent;border:1px solid transparent;border-radius:.7rem;text-align:left;cursor:pointer}.session-option:hover,.session-option:focus-visible{background:#ffffff0a;border-color:#ffffff12;outline:none}.session-option.active{color:var(--accent);background:#ead7780d;border-color:#ead77826}.session-option:disabled{cursor:default;opacity:.62}.session-option span{overflow:hidden;font:500 .78rem ui-monospace,monospace;text-overflow:ellipsis;white-space:nowrap}.session-option small{flex:none;color:var(--green);font:.55rem ui-monospace,monospace;text-transform:uppercase;letter-spacing:.07em}.session-menu-empty{margin:0;padding:.8rem .7rem;color:var(--muted);font-size:.72rem}.eyebrow{margin:0;color:var(--accent);font-size:.68rem;font-weight:800;letter-spacing:.2em;text-transform:uppercase}.mission-core{position:relative;display:grid;place-items:center;width:min(19rem,100%);aspect-ratio:1;margin:0 auto}.orbit{position:absolute;border:1px solid #ffffff22;border-radius:50%}.orbit:before,.orbit:after{content:"";position:absolute;top:0;left:50%;width:.45rem;height:.45rem;background:var(--accent);border:0;border-radius:50%;transform:translate(-50%,-50%)}.orbit-outer{inset:6%;animation:mission-orbit 18s linear infinite}.orbit-outer:after{top:100%}.orbit-inner{inset:22%;border-style:dashed;animation:mission-orbit 12s linear infinite reverse}.orbit-inner:before,.orbit-inner:after{background:var(--green)}.orbit-inner:after{top:100%}.core-mark{display:grid;place-items:center;width:42%;aspect-ratio:1;border:1px solid #ead77845;border-radius:50%}.core-mark img{width:100%;height:100%}.core-label{position:absolute;right:3%;bottom:13%;padding:.5rem .65rem;background:#15161dc7;border:1px solid #ffffff1c;border-radius:.65rem;backdrop-filter:blur(12px)}.core-label strong,.core-label small{display:block}.core-label strong{color:var(--green);font-size:.58rem;letter-spacing:.12em}.core-label small{max-width:8rem;margin-top:.2rem;overflow:hidden;color:var(--muted);font:.55rem ui-monospace,monospace;text-overflow:ellipsis;white-space:nowrap}@keyframes mission-orbit{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.orbit{animation:none}}.section{margin-top:3rem;scroll-margin-top:8rem}.section-heading{display:flex;align-items:end;justify-content:space-between;margin-bottom:1rem;scroll-margin-top:8rem}.section-title{margin:0;color:#f2eff9;font-size:clamp(1.35rem,3vw,2rem);font-weight:560;letter-spacing:-.035em}.section-status{padding:.38rem .6rem;color:var(--green);background:#9ed68a0b;border:1px solid #9ed68a24;border-radius:999px;font:.58rem ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em}.agent-grid{display:grid;grid-template-columns:1fr;align-items:start;gap:.8rem}.agent-filters{display:flex;gap:.2rem;padding:.2rem;border:1px solid var(--rally-border);border-radius:999px}.agent-filter{padding:.45rem .65rem;border:0;border-radius:999px;background:transparent;color:var(--muted);font-size:.75rem;cursor:pointer}.agent-filter[aria-pressed=true]{background:var(--accent);color:var(--accent-ink)}.agent-filter:focus-visible{outline:2px solid var(--green);outline-offset:2px}.workspace-grid{column-count:1;column-gap:.8rem}.agent-card,.workspace-card{position:relative;background:#1d1e27;border:1px solid #ffffff17;border-radius:1rem;box-shadow:inset 0 1px #ffffff08,0 1rem 3rem #0002}.agent-card{display:flex;align-items:center;gap:.8rem;min-height:5rem;padding:.85rem 1rem;overflow:hidden;text-align:left;cursor:pointer;transition:border-color .18s ease,transform .18s ease,background .18s ease}.agent-card:hover,.agent-card:focus-visible{background:#24252f;border-color:#ead77846;outline:none;transform:translateY(-2px)}.agent-card:disabled{cursor:default;opacity:.65}.agent-card:disabled:hover{background:#1d1e27;border-color:#ffffff17;transform:none}.agent-card strong,.agent-card small{display:block}.agent-card .agent-context{overflow:hidden;color:var(--muted);font-size:.75rem;font-weight:550;text-overflow:ellipsis;white-space:nowrap}.agent-card .agent-session-title{display:-webkit-box;margin-top:.3rem;overflow:hidden;color:#f2eff9;font-size:.95rem;font-weight:600;line-height:1.35;overflow-wrap:anywhere;-webkit-box-orient:vertical;-webkit-line-clamp:2}.agent-card .agent-session-title.absent{color:var(--muted);font-weight:400}.agent-copy{min-width:0;flex:1}.agent-state{padding:.25rem .45rem;color:var(--pane-status-color);background:#6e738d0d;border-radius:999px;font:.55rem ui-monospace,monospace;text-transform:uppercase}.agent-state,.pane-tile{--pane-status-color: var(--status-unknown)}.agent-state.idle,.pane-tile.idle{--pane-status-color: var(--status-idle)}.agent-state.working,.pane-tile.working{--pane-status-color: var(--status-working)}.agent-state.done,.pane-tile.done{--pane-status-color: var(--status-done)}.agent-state.blocked,.pane-tile.blocked{--pane-status-color: var(--status-blocked)}.agent-state.idle{background:#94a1430d}.agent-state.working{background:#e0a1540d}.agent-state.done{background:#b8cf6a0d}.agent-state.blocked{background:#cf5a440d}.agent-card>.mission-icon{width:1rem;height:1rem;color:var(--muted);transition:transform .18s ease}.agent-card:hover>.mission-icon{color:var(--accent);transform:translate(2px)}.workspace-card{width:100%;margin:0 0 .8rem;padding:0;overflow:hidden;break-inside:avoid}.workspace-head{display:flex;align-items:start;justify-content:space-between;gap:.8rem;padding:1rem}.workspace-title{min-width:0}.workspace-name-row{display:flex;min-width:0;align-items:center;gap:.5rem}.workspace-presence{width:.48rem;height:.48rem;flex:none;background:#6e6c7c;border-radius:50%}.workspace-presence.active{background:var(--green);box-shadow:0 0 .7rem #9ed68a55}.workspace-name-row h3{min-width:0;margin:0;overflow:hidden;color:#f2eff9;font-size:1.02rem;font-weight:600;letter-spacing:-.02em;text-overflow:ellipsis;white-space:nowrap}.workspace-branch{min-width:0;padding:.18rem .38rem;overflow:hidden;color:var(--accent);background:#dbc66f0b;border:1px solid #dbc66f1c;border-radius:999px;font:.52rem ui-monospace,monospace;text-overflow:ellipsis;white-space:nowrap}.workspace-title>p{margin:.38rem 0 0 .98rem;overflow:hidden;color:var(--muted);font:.65rem ui-monospace,monospace;text-overflow:ellipsis;white-space:nowrap}.workspace-tab-total{flex:none;padding:.28rem .46rem;color:#b8b5c5;background:#ffffff08;border-radius:999px;font:.56rem ui-monospace,monospace;white-space:nowrap}.workspace-tabs{border-top:1px solid #ffffff12}.workspace-tab{padding:.78rem 1rem .9rem;border-bottom:1px solid #ffffff0e}.workspace-tab:last-child{border-bottom:0}.workspace-tab-head{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.5rem}.tab-presence{width:.42rem;height:.42rem;border:1px solid #777587;border-radius:50%}.workspace-tab.active .tab-presence{background:var(--accent);border-color:var(--accent);box-shadow:0 0 .6rem #dbc66f45}.workspace-tab-head strong{overflow:hidden;color:#dedbe8;font-size:.75rem;font-weight:570;text-overflow:ellipsis;white-space:nowrap}.tab-pane-total{min-width:1.35rem;padding:.14rem .3rem;color:var(--muted);background:#ffffff08;border-radius:999px;font:.52rem ui-monospace,monospace;text-align:center}.pane-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(7.25rem,1fr));gap:.4rem;margin-top:.65rem}.pane-tile{display:flex;min-width:0;align-items:center;gap:.5rem;padding:.52rem .58rem;color:#d9d7e7;background:#111219a6;border:1px solid #ffffff12;border-radius:.65rem;text-align:left}button.pane-tile{cursor:pointer;transition:background .16s ease,border-color .16s ease,transform .16s ease}button.pane-tile:hover,button.pane-tile:focus-visible{background:#252631;border-color:#ffffff24;outline:none;transform:translateY(-1px)}.pane-tile.focused{border-color:#dbc66f59;box-shadow:inset 0 0 0 1px #dbc66f18}.pane-presence{width:.42rem;height:.42rem;flex:none;background:var(--pane-status-color);border-radius:50%}.pane-tile.working .pane-presence{box-shadow:0 0 .55rem #e0a15455}.pane-copy{min-width:0;flex:1}.pane-copy strong,.pane-copy small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.pane-copy strong{font-size:.68rem;font-weight:570}.pane-copy small{margin-top:.16rem;color:var(--pane-status-color);font:.52rem ui-monospace,monospace;text-transform:uppercase;letter-spacing:.04em}.agent-state.terminal,.pane-tile.terminal .pane-copy small,.pane-tile.view .pane-copy small{color:var(--status-unknown-text)}.pane-tile>.mission-icon{width:.8rem;height:.8rem;flex:none;color:#777587}.pane-tile.view{color:var(--muted)}.workspace-empty{margin:0;padding:1rem;color:var(--muted);font-size:.7rem;text-align:center}.ghost,.primary,.key{min-height:2.5rem;padding:.5rem .8rem;border:1px solid var(--line);border-radius:.65rem;cursor:pointer}.ghost,.key{background:var(--panel)}.primary{color:var(--accent-ink);background:var(--accent);border-color:var(--accent);font-weight:800}button:disabled{cursor:not-allowed;opacity:.45}.empty-state{display:grid;place-items:center;align-content:center;min-height:calc(100dvh - 8rem);padding:2rem;text-align:center}.empty-state h1{margin:1rem 0 .4rem}.empty-state p{max-width:28rem;margin:0 0 1.3rem;color:var(--muted)}.pair-form{display:flex;gap:.5rem;width:min(28rem,100%)}.pair-input{flex:1;min-width:0;padding:.65rem .7rem;color:#d9d7e7;background:var(--bg);border:1px solid var(--line);border-radius:.6rem;font:.8rem ui-monospace,monospace}.pair-input:focus-visible{outline:2px solid var(--accent);outline-offset:1px}.pair-error{margin:.8rem 0 0;color:#f0a0a0}.pulse{width:1rem;height:1rem;background:var(--accent);border-radius:50%;box-shadow:0 0 #ead77866;animation:pulse 1.4s infinite}@keyframes pulse{70%{box-shadow:0 0 0 1rem #ead77800}}.toast{position:fixed;z-index:30;right:1rem;bottom:calc(1rem + env(safe-area-inset-bottom));left:1rem;padding:.9rem 1rem;color:#ffe4e8;background:#4b252d;border:1px solid #ed8f9e66;border-radius:.8rem;box-shadow:0 1rem 3rem #0007}.toast.success{color:#e6ffdf;background:#243b27;border-color:#9ed68a66}.device-overlay{position:fixed;z-index:25;inset:0;display:grid;place-items:end center;padding:1rem;background:#090a10aa;backdrop-filter:blur(10px)}.device-panel{width:min(32rem,100%);max-height:calc(100dvh - 2rem);overflow-y:auto;overscroll-behavior:contain;padding:1.2rem;background:var(--panel-raised);border:1px solid var(--line);border-radius:1.25rem;box-shadow:0 1.5rem 5rem #000a}.device-panel-head{display:flex;align-items:start;justify-content:space-between;gap:1rem}.device-panel h2{margin:.25rem 0 0;font-size:1.25rem}.device-close{padding:.35rem .45rem;color:var(--muted);background:transparent;border:0;cursor:pointer}.device-close:hover,.device-close:focus-visible{color:#f4f1ff;outline:none}.device-copy{margin:1rem 0;color:var(--muted)}.device-limit-row{display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.75rem 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.device-limit{min-width:4rem;padding:.45rem .6rem;color:inherit;background:var(--bg);border:1px solid var(--line);border-radius:.6rem}.device-url-setting{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:.65rem;padding-top:.85rem}.device-url-label{display:grid;gap:.4rem;min-width:0;color:#e2dfeb;font-size:.82rem}.device-url-input{width:100%;min-width:0;padding:.65rem .7rem;color:#e7e4ef;background:var(--bg);border:1px solid var(--line);border-radius:.65rem;font:.75rem ui-monospace,monospace}.device-url-input:focus{border-color:var(--rally-border-focus);outline:none;box-shadow:0 0 0 2px #dbc66f14}.device-url-save{min-height:2.35rem;white-space:nowrap}.device-help{margin:.65rem 0 0;color:var(--muted);font-size:.78rem;line-height:1.45}.device-help+.device-help{margin-bottom:1rem}.device-pair{width:100%}.device-browser-access{display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-top:1.1rem;padding-top:1rem;border-top:1px solid var(--line)}.device-browser-info{display:grid;gap:.25rem;min-width:0}.device-browser-info strong{color:#e2dfeb;font-size:.85rem;font-weight:600}.device-browser-info span{color:var(--muted);font-size:.75rem}.device-disconnect{display:inline-flex;flex:0 0 auto;align-items:center;justify-content:center;gap:.45rem;min-height:2.75rem;padding:.55rem .8rem;color:var(--red);background:#ed8f9e08;border:1px solid #ed8f9e33;border-radius:.65rem;font-size:.82rem;font-weight:600;cursor:pointer}.device-disconnect .mission-icon{width:1rem;height:1rem}.device-disconnect:hover:not(:disabled){background:#ed8f9e14;border-color:#ed8f9e66}.device-disconnect:focus-visible{outline:2px solid var(--red);outline-offset:3px}.pairing-card{padding:.9rem;background:#11121988;border:1px solid #ead77855;border-radius:.8rem}.pairing-card strong,.pairing-card p{display:block}.pairing-card p{margin:.35rem 0 .7rem;color:var(--muted);font-size:.82rem}.pairing-qr-wrap{display:grid;place-items:center;margin:.8rem 0}.pairing-qr{display:block;width:min(13.75rem,70vw);height:auto;padding:.4rem;background:#fff;border-radius:.75rem;image-rendering:pixelated}.pairing-link{width:100%;padding:.65rem .7rem;color:#d9d7e7;background:var(--bg);border:1px solid var(--line);border-radius:.6rem;font:.75rem ui-monospace,monospace}.pairing-actions{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.7rem}@media(max-width:34rem){.device-url-setting{grid-template-columns:1fr}.device-url-save{width:100%}}.terminal-screen{--terminal-circle-size: 3.125rem;--terminal-header-top: max(.5rem, env(safe-area-inset-top));position:fixed;top:var(--terminal-viewport-top, 0);right:0;left:0;display:grid;grid-template-rows:minmax(0,1fr);width:100%;max-width:100vw;height:var(--terminal-viewport-height, 100dvh);overflow:hidden;background:#0d0e14}.terminal-screen:before{content:"";position:absolute;z-index:0;inset:0;pointer-events:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64' viewBox='0 0 64 64'%3E%3Cpath d='M0 0H64M0 0V64' fill='none' stroke='%23ffffff' stroke-opacity='.045' stroke-width='1'/%3E%3C/svg%3E");background-size:64px 64px}.terminal-main{position:relative;z-index:1;grid-column:1;grid-row:1;display:grid;grid-template-rows:minmax(0,1fr);min-width:0;min-height:0}.terminal-sidebar,.terminal-workspace-sidebar,.terminal-navigation,.terminal-navigation-header{display:none}.terminal-header{position:absolute;z-index:5;top:var(--terminal-header-top);left:50%;display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:.65rem;width:min(62rem,calc(100% - 2rem));margin:0;padding:0;transform:translate(-50%)}.terminal-header .terminal-workspace-brand{width:max-content;margin:0}.terminal-pane-switcher{position:relative;justify-self:end;width:max-content;max-width:100%;min-width:0}.terminal-pane-selector{display:grid;place-items:center;width:var(--terminal-circle-size);height:var(--terminal-circle-size);padding:0;color:#d9d7e7;background:#1b1c24b8;border:1px solid var(--rally-border);border-radius:50%;cursor:pointer;box-shadow:inset 0 1px #ffffff12,0 .75rem 2rem #0003;-webkit-backdrop-filter:blur(24px) saturate(150%);backdrop-filter:blur(24px) saturate(150%)}.terminal-pane-selector:hover,.terminal-pane-selector:focus-visible,.terminal-pane-selector[aria-expanded=true]{background:#22232cdd;border-color:var(--rally-border-focus);outline:none}.terminal-pane-dot{width:.6rem;height:.6rem;background:currentColor;border-radius:50%}.terminal-pane-selector[aria-expanded=true]{color:var(--accent)}.terminal-sidebar-heading{display:flex;align-items:center;justify-content:space-between;gap:.5rem;margin-bottom:.6rem;padding-inline:.2rem}.terminal-sidebar-heading h2{margin:0;color:#ece9f3;font-size:.9rem;font-weight:600;letter-spacing:-.02em}.terminal-sidebar-count{padding:.15rem .4rem;color:var(--muted);background:#ffffff06;border-radius:.4rem;font:.65rem ui-monospace,monospace}.terminal-sidebar-heading>.terminal-sidebar-count{margin-left:auto}.sidebar-collapse-toggle{display:none;flex:none;place-items:center;width:2rem;height:2rem;padding:0;color:var(--muted);background:transparent;border:0;border-radius:.4rem;cursor:pointer}.sidebar-collapse-toggle:hover{color:var(--accent);background:#ffffff06}.sidebar-collapse-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}.sidebar-collapse-toggle[aria-expanded=false] .sidebar-collapse-arrow{transform:rotate(180deg)}.terminal-sidebar-toolbar{display:flex;flex:none;align-items:center;gap:.75rem;margin-bottom:.5rem}.terminal-sidebar-filters{flex:1;min-width:0}.terminal-sidebar-filters .agent-filter{flex:1;padding:.35rem .4rem;font-size:.7rem;white-space:nowrap}.terminal-sidebar-close{display:none;flex:none}.terminal-sidebar-list{flex:1;min-height:0;padding:.2rem;overflow-y:auto;overscroll-behavior:contain;overflow-anchor:none;scrollbar-width:thin;scrollbar-color:#383944 transparent}.terminal-sidebar-pane{display:block;width:100%;min-height:44px;margin-bottom:.1rem;padding:.4rem .5rem;color:#dedbe8;background:transparent;border:0;border-radius:.5rem;text-align:left;cursor:pointer;transition:background .15s ease}.terminal-sidebar-pane:hover{background:#ffffff06}.terminal-sidebar-pane:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}.terminal-sidebar-pane.active{background:#ead7780b}.terminal-sidebar-pane{--pane-status-color: var(--status-unknown)}.terminal-sidebar-pane.idle{--pane-status-color: var(--status-idle)}.terminal-sidebar-pane.working{--pane-status-color: var(--status-working)}.terminal-sidebar-pane.done{--pane-status-color: var(--status-done)}.terminal-sidebar-pane.blocked{--pane-status-color: var(--status-blocked)}.terminal-sidebar-dot{width:.42rem;height:.42rem;flex:none;background:var(--pane-status-color);border:1px solid var(--pane-status-color);border-radius:50%}.terminal-sidebar-pane.idle .terminal-sidebar-dot,.terminal-sidebar-pane.terminal .terminal-sidebar-dot{background:transparent}.terminal-sidebar-copy{display:block;min-width:0}.terminal-sidebar-label{display:flex;align-items:center;gap:.25rem;min-width:0;font-size:.8rem;font-weight:600;line-height:1.3;white-space:nowrap}.terminal-sidebar-state{flex:none;color:var(--pane-status-color);font:500 .72rem ui-monospace,monospace}.terminal-sidebar-pane.terminal .terminal-sidebar-state{color:var(--status-unknown-text)}.terminal-sidebar-agent{min-width:0;overflow:hidden;color:#aaa7b7;font-size:.8rem;font-weight:500;text-overflow:ellipsis}.terminal-sidebar-pane.active .terminal-sidebar-agent{color:var(--accent)}.terminal-sidebar-detail{display:flex;gap:.25rem;min-width:0;margin-top:.1rem;padding-left:.67rem;color:var(--muted);font-size:.675rem;line-height:1.3;white-space:nowrap}.terminal-sidebar-title{min-width:0;overflow:hidden;text-overflow:ellipsis}.terminal-sidebar-context{flex:none;max-width:50%;overflow:hidden;text-overflow:ellipsis}.terminal-sidebar-title[hidden]+.terminal-sidebar-context{max-width:100%}.terminal-sidebar-empty{margin:1.5rem .5rem;color:var(--muted);font-size:.8rem;line-height:1.6}.terminal-workspace-brand{display:flex;flex:none;align-items:center;gap:.55rem;width:100%;margin-bottom:1.2rem;padding:.25rem .4rem;color:var(--accent);background:transparent;border:0;border-radius:.4rem;font-size:1.1rem;font-weight:650;text-align:left;cursor:pointer}.terminal-workspace-brand img{display:block;width:2.1rem;height:2.1rem}.terminal-workspace-brand:hover{background:#ffffff06}.terminal-workspace-brand:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}.terminal-workspace-topbar{display:flex;flex:none;align-items:center;gap:.4rem;margin-bottom:1.2rem}.terminal-workspace-topbar .terminal-workspace-brand{flex:1;min-width:0;width:auto;margin:0}.terminal-workspace-footer{flex:none;padding:.9rem .2rem 0}.terminal-workspace-footer .mission-dock{position:static;margin:0;padding:0;background:transparent;border:0;border-radius:0;box-shadow:none;backdrop-filter:none}.terminal-workspace-footer .mission-nav-status{border:0;border-radius:0}.terminal-workspace-group{margin-bottom:.25rem}.terminal-workspace-label{display:flex;align-items:center;gap:.4rem;width:100%;min-width:0;min-height:2.1rem;padding:.35rem .4rem;background:transparent;border:0;border-radius:.4rem;cursor:pointer;text-align:left}.terminal-workspace-label:hover{background:#ffffff06}.terminal-workspace-label:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}.terminal-workspace-name{min-width:0;overflow:hidden;color:#dedbe8;font-size:.8rem;font-weight:600;text-overflow:ellipsis;white-space:nowrap}.terminal-workspace-copy{display:block;width:100%;min-width:0}.terminal-workspace-line{display:flex;align-items:center;gap:.4rem;min-width:0}.terminal-workspace-branch{flex:0 1 auto;min-width:0;max-width:45%;overflow:hidden;color:var(--muted);font:.65rem ui-monospace,monospace;text-overflow:ellipsis;white-space:nowrap}.terminal-workspace-branch[hidden]{display:none}.terminal-workspace-group.current .terminal-workspace-branch{color:var(--green)}.terminal-workspace-path{display:block;min-width:0;margin-top:.2rem;padding-left:.8rem;overflow:hidden;color:var(--muted);font:.65rem/1.4 ui-monospace,monospace;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left;unicode-bidi:plaintext}.terminal-workspace-dot{width:.4rem;height:.4rem;flex:none;border:1px solid var(--muted);border-radius:50%}.terminal-workspace-group.current .terminal-workspace-dot{background:var(--green);border-color:var(--green)}.terminal-output{position:relative;z-index:1;grid-row:1;width:100%;max-width:64rem;min-width:0;min-height:0;margin:0 auto;padding:5rem 1rem 1.1rem;overflow:auto;overflow-x:hidden;color:#d9d7e7;background:#0d0e14;outline:none;overscroll-behavior:contain;overflow-anchor:none;scrollbar-width:none;white-space:pre-wrap;overflow-wrap:anywhere;word-break:normal;tab-size:4;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,Symbols Nerd Font Mono,Symbols Nerd Font,MesloLGS NF,MesloLGS Nerd Font Mono,JetBrainsMono Nerd Font Mono,JetBrainsMono Nerd Font,FiraCode Nerd Font Mono,Hack Nerd Font Mono,monospace;font-size:.9rem;line-height:1.5}.terminal-cell-probe{position:absolute;visibility:hidden;white-space:pre;pointer-events:none}.terminal-output:focus-visible{box-shadow:inset 0 0 0 1px #ead77855}.terminal-output::-webkit-scrollbar{display:none}.terminal-status{margin-top:1rem;padding:.65rem .8rem;color:#ffe4e8;background:#4b252d;border-radius:.65rem}.terminal-caret{display:inline-block;position:relative;width:0;height:1em;vertical-align:-.14em}.terminal-caret:after{content:"";position:absolute;bottom:-.04em;left:0;width:.62em;height:1.12em;background:var(--accent);border-radius:.08em;animation:terminal-caret-blink 1.05s steps(1,end) infinite}@keyframes terminal-caret-blink{50%{opacity:0}}@media(prefers-reduced-motion:reduce){.terminal-caret:after{animation:none}}.terminal-controls-wrap{position:absolute;z-index:6;bottom:max(.75rem,env(safe-area-inset-bottom));left:50%;display:flex;justify-content:flex-end;width:min(64rem,100%);padding-right:max(1rem,env(safe-area-inset-right));background:transparent;pointer-events:none;transform:translate(-50%)}.terminal-controls{display:flex;align-items:center;justify-content:flex-end;width:max-content;min-width:var(--terminal-circle-size);max-width:100%;min-height:var(--terminal-circle-size);margin:0;padding:.22rem;background:#1b1c24b8;border:1px solid var(--rally-border);border-radius:999px;box-shadow:inset 0 1px #ffffff12,0 .7rem 1.8rem #0005;-webkit-backdrop-filter:blur(24px) saturate(150%);backdrop-filter:blur(24px) saturate(150%);pointer-events:auto}.keyboard-active .terminal-controls{border-color:var(--rally-border-focus)}.terminal-input-hint{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}.terminal-tools{display:flex;flex:0 1 auto;align-items:center;gap:.08rem;min-width:0;max-width:0;overflow:hidden;opacity:0;pointer-events:none;transform:translate(1rem);transition:max-width .24s ease,opacity .16s ease,transform .24s ease}.controls-expanded .terminal-tools{max-width:min(32rem,calc(100vw - 5rem));overflow-x:auto;opacity:1;pointer-events:auto;transform:translate(0)}.terminal-tools::-webkit-scrollbar{display:none}.terminal-controls-toggle{display:grid;flex:0 0 auto;place-items:center;width:calc(var(--terminal-circle-size) - .44rem - 2px);height:calc(var(--terminal-circle-size) - .44rem - 2px);padding:0;color:var(--accent);background:#24252e;border:0;border-radius:50%;cursor:pointer}.terminal-controls-toggle:before{content:"";width:.32rem;height:.32rem;background:currentColor;box-shadow:.46rem 0 currentColor,0 .46rem currentColor,.46rem .46rem currentColor;transform:translate(-.23rem,-.23rem)}.terminal-controls-toggle:hover,.terminal-controls-toggle:focus-visible{color:#fff7cf;background:#30313b;outline:none}.controls-expanded .terminal-controls-toggle{color:#d9d7e7}.controls-expanded .terminal-controls-toggle:before{content:"×";width:auto;height:auto;background:transparent;box-shadow:none;font:400 1.35rem/1 ui-monospace,monospace;transform:none}.terminal-tool{display:grid;flex:0 0 auto;place-items:center;min-width:2.1rem;height:2.1rem;padding:0 .5rem;color:#c2c0cc;background:transparent;border:0;border-radius:999px;font:.68rem ui-monospace,monospace;cursor:pointer}.terminal-tool:hover,.terminal-tool:focus-visible{color:#f4f1ff;background:#ffffff0d;outline:none}.terminal-tool:active,.terminal-tool[aria-pressed=true]{color:var(--accent-ink);background:var(--accent)}.terminal-tool:disabled{cursor:not-allowed;opacity:.38}.keyboard-toggle{color:var(--accent);font-family:inherit;font-size:.9rem}.attach-file{color:var(--accent);font-family:inherit;font-size:1.2rem;font-weight:600}.terminal-file-input{position:fixed;width:1px;height:1px;opacity:0;pointer-events:none}.terminal-screen.file-drag-active:after{content:"Drop files to attach";position:fixed;z-index:20;inset:1rem;display:grid;place-items:center;color:var(--accent);background:#111219dd;border:2px dashed #ead77888;border-radius:1.5rem;font-weight:800;letter-spacing:.04em;pointer-events:none}.terminal-input-proxy{position:fixed;right:0;bottom:0;width:1px;height:1px;padding:0;overflow:hidden;opacity:0;border:0;outline:0;resize:none;pointer-events:none}.terminal-input-surface{flex:0 0 0;width:0}.terminal-input-placeholder{display:none}@media(pointer:coarse){.terminal-output{padding-bottom:calc(var(--terminal-circle-size) + 1.5rem)}.controls-expanded .terminal-output{padding-bottom:calc(3 * var(--terminal-circle-size) + 2rem)}.terminal-controls-wrap{flex-wrap:wrap;gap:.5rem;padding-left:max(1rem,env(safe-area-inset-left))}.terminal-input-surface{position:relative;flex:1 1 0;min-width:0;pointer-events:auto}.controls-expanded .terminal-input-surface{flex-basis:100%}.controls-expanded .terminal-controls{width:100%;align-items:flex-start;gap:.25rem;border-radius:1rem}.controls-expanded .terminal-tools{flex:1 1 auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(44px,1fr));max-width:none;overflow:visible}.terminal-tools .terminal-tool{min-width:44px;min-height:44px;padding-inline:.25rem;touch-action:manipulation}.terminal-input-proxy{position:static;display:block;width:100%;height:var(--terminal-circle-size);padding:.85rem .8rem;opacity:1;color:#d9d7e7;background:#1b1c24e8;border:1px solid var(--rally-border);border-radius:.85rem;font:16px/1.3 ui-monospace,monospace;caret-color:var(--accent);pointer-events:auto;-webkit-user-select:text;user-select:text;-webkit-touch-callout:default}.terminal-input-proxy:focus{border-color:var(--rally-border-focus)}.terminal-input-proxy[data-empty=true]+.terminal-input-placeholder{display:block;position:absolute;top:50%;left:.8rem;right:.8rem;overflow:hidden;color:var(--muted);font-size:.8rem;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;transform:translateY(-50%)}}@media(pointer:coarse)and (max-width:360px){.controls-expanded .terminal-output{padding-bottom:calc(4 * var(--terminal-circle-size) + 2rem)}}@media(min-width:1024px){.terminal-screen,.dashboard-layout{--workspace-sidebar-width: clamp(11rem, 14vw, 14rem);--pane-sidebar-width: clamp(12rem, 16vw, 16rem);display:grid;grid-template-columns:var(--workspace-sidebar-width) minmax(0,1fr) var(--pane-sidebar-width);grid-template-rows:minmax(0,1fr);gap:0;padding:.75rem;background:var(--bg)}.terminal-screen.workspaces-collapsed,.dashboard-layout.workspaces-collapsed{--workspace-sidebar-width: 3.5rem}.terminal-screen.panes-collapsed,.dashboard-layout.panes-collapsed{--pane-sidebar-width: 3.5rem}.dashboard-layout{height:100dvh;min-height:0;overflow:hidden}.web-content-frame{position:relative;z-index:1;grid-column:2;grid-row:1;display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr);min-width:0;min-height:0;overflow:hidden;background:#13141c;border:1px solid #ffffff14;border-radius:1rem}.dashboard-layout .dashboard{grid-column:1;grid-row:1;display:grid;align-content:safe center;width:100%;min-width:0;min-height:0;padding:2rem 1.5rem;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:#383944 transparent}.dashboard-layout .hero-layout{grid-template-columns:minmax(5rem,1fr) minmax(14rem,1.4fr) minmax(5rem,1fr)}.terminal-main{grid-column:1;grid-row:1}.terminal-navigation{display:contents}.dashboard-header,.terminal-header{display:none}.terminal-output{max-width:none;padding-top:1rem}.terminal-pane-switcher{display:none}.terminal-sidebar,.terminal-workspace-sidebar{z-index:2;display:flex;flex-direction:column;min-width:0;min-height:0;padding:.9rem .65rem .7rem;background:#111219}.terminal-sidebar{grid-column:3;grid-row:1;padding:0 0 .2rem .75rem}.terminal-workspace-sidebar{grid-column:1;grid-row:1;padding:0 .75rem .2rem 0}.terminal-workspace-topbar,.terminal-sidebar>.terminal-sidebar-heading{flex:none;min-height:3rem;margin-bottom:.5rem;padding-inline:.2rem}.sidebar-collapse-toggle{display:grid}.terminal-sidebar.collapsed,.terminal-workspace-sidebar.collapsed{padding-inline:.35rem}.terminal-sidebar.collapsed .terminal-sidebar-heading{justify-content:center}.collapsed .terminal-sidebar-heading h2,.collapsed .terminal-sidebar-count,.collapsed .terminal-sidebar-list,.collapsed .terminal-sidebar-toolbar{display:none}.terminal-workspace-sidebar.collapsed .terminal-workspace-topbar{flex-direction:column;gap:0;min-height:calc(4.25rem + 1px)}.terminal-workspace-sidebar.collapsed .terminal-workspace-brand{justify-content:center;padding:0}.terminal-workspace-sidebar.collapsed .terminal-workspace-brand span{display:none}.terminal-workspace-sidebar.collapsed .terminal-workspace-footer{margin-top:auto;padding-inline:0}.terminal-workspace-sidebar.collapsed .mission-dock{flex-direction:column;gap:0}.terminal-sidebar-pane{min-height:0;padding:.3rem .4rem}}@media not all and (min-width:1024px){.dashboard-layout{display:flex;flex-direction:column;min-height:100dvh}.dashboard-header{display:flex;flex:none;align-items:center;justify-content:space-between;gap:.75rem;padding:max(.5rem,env(safe-area-inset-top)) max(1rem,env(safe-area-inset-right)) .5rem max(1rem,env(safe-area-inset-left))}.dashboard-header .terminal-workspace-brand{width:max-content;margin:0}.dashboard-layout .dashboard{display:grid;flex:1;align-content:center;min-width:0;padding:1rem 1rem max(1rem,env(safe-area-inset-bottom))}.dashboard-header .terminal-pane-selector{width:3.125rem;height:3.125rem}.dashboard-layout.pane-navigation-open .terminal-navigation{position:fixed}.pane-navigation-open .terminal-navigation{position:absolute;z-index:10;inset:0;display:flex;flex-direction:column;min-width:0;min-height:0;padding:max(.5rem,env(safe-area-inset-top)) max(1rem,env(safe-area-inset-right)) calc(3.5rem + env(safe-area-inset-bottom)) max(1rem,env(safe-area-inset-left));background:var(--bg)}.pane-navigation-open .terminal-navigation-header{display:flex;flex:none;align-items:center;justify-content:space-between;gap:.75rem;margin-bottom:.75rem}.terminal-navigation-header .terminal-workspace-brand{width:max-content;margin:0}.pane-navigation-open .terminal-workspace-sidebar{display:flex;flex:0 1 auto;flex-direction:column;min-height:0;max-height:30%;margin-bottom:.75rem;padding-bottom:.75rem;border-bottom:1px solid #ffffff0d}.pane-navigation-open .terminal-sidebar{display:flex;flex:1;flex-direction:column;min-height:0}.terminal-navigation .terminal-workspace-topbar{display:none}.terminal-navigation .terminal-workspace-footer{position:absolute;bottom:max(.5rem,env(safe-area-inset-bottom));left:max(1rem,env(safe-area-inset-left));padding:0}.terminal-navigation .terminal-workspace-label{min-height:44px}.terminal-sidebar-toolbar{margin-bottom:.5rem}.terminal-sidebar-close{display:grid}.terminal-sidebar-list{padding:0}.terminal-sidebar-pane{border-radius:0}}@media(max-width:800px){.hero-layout{grid-template-areas:"center center" "left right";grid-template-columns:repeat(2,minmax(0,1fr))}.hero-center{grid-area:center}.stats-left{grid-area:left}.stats-right{grid-area:right}.mission-core{width:min(17rem,70vw)}}@media(max-width:600px){.dashboard{padding-inline:.7rem}.mission-dock{top:.45rem;margin-bottom:.7rem}.mission-nav-button{width:2.65rem;height:2.65rem}.mission-nav-logo{width:2.35rem;height:2.35rem}.mission-nav-status{width:2.65rem;height:2.65rem}.mission-hero{min-height:0;padding:1.25rem 0}.hero-layout{gap:1.4rem .9rem}.hero-stat-column{gap:1.2rem}.mission-stat{min-width:0;padding:0 0 .65rem}.mission-stat>strong{font-size:1.9rem}.mission-stat>div{margin-top:.22rem}.mission-stat span{font-size:.76rem}.mission-core{position:relative;right:auto;bottom:auto;width:min(14rem,68vw);opacity:1}.core-label{display:block}.hero-session-wrap{width:min(16rem,100%)}.section{margin-top:2.3rem}.section-heading{align-items:center}.section-status{max-width:9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.workspace-head{padding:.9rem}.workspace-tab{padding-inline:.9rem}.pane-grid{grid-template-columns:repeat(auto-fit,minmax(6.5rem,1fr))}.terminal-screen:before{display:none}.terminal-header{width:calc(100% - 1.8rem);gap:.45rem}.terminal-output{padding-top:4.8rem;padding-inline:.8rem;font-size:.9rem;line-height:1.6}.terminal-controls-wrap{bottom:max(.55rem,env(safe-area-inset-bottom));padding-right:max(.9rem,env(safe-area-inset-right))}.terminal-tools{gap:.05rem}.terminal-tool{min-width:2.35rem;height:2.35rem;padding-inline:.5rem;font-size:.82rem}.keyboard-toggle{font-size:1.05rem}.attach-file{font-size:1.25rem}}@media(min-width:720px){.dashboard{padding:2rem}.agent-grid{grid-template-columns:repeat(3,1fr)}.workspace-grid{column-count:2}.toast{right:2rem;left:auto;width:min(28rem,calc(100vw - 4rem))}.device-overlay{place-items:center}}@media(min-width:1100px){.workspace-grid{column-count:3}} diff --git a/src/web/assets/app.js b/src/web/assets/app.js index 7d3bbab66..8a2e43f20 100644 --- a/src/web/assets/app.js +++ b/src/web/assets/app.js @@ -1,5 +1,5 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const n of document.querySelectorAll('link[rel="modulepreload"]'))i(n);new MutationObserver(n=>{for(const r of n)if(r.type==="childList")for(const o of r.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function e(n){const r={};return n.integrity&&(r.integrity=n.integrity),n.referrerPolicy&&(r.referrerPolicy=n.referrerPolicy),n.crossOrigin==="use-credentials"?r.credentials="include":n.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function i(n){if(n.ep)return;n.ep=!0;const r=e(n);fetch(n.href,r)}})();class f extends Error{code;constructor(t,e="bridge_error"){super(t),this.code=e}}const Mt=/^[A-Za-z0-9._:-]{1,128}$/;class $t extends EventTarget{url;credential;storeTicket;#e;#t=new Map;#i=new Map;#r=0;#n;#s;constructor(t,e,i){super(),this.url=t,this.credential=e,this.storeTicket=i}get ready(){return this.#n}connect(t){return this.#n&&this.#e?.readyState===WebSocket.OPEN?Promise.resolve(this.#n):this.#s?this.#s:(this.#s=this.#u(t).finally(()=>{this.#s=void 0}),this.#s)}async#u(t){this.close("replaced");const e=new WebSocket(this.url);this.#e=e;const i=await new Promise((n,r)=>{const o=setTimeout(()=>h(new f("Bridge connection timed out","timeout")),8e3),a=()=>h(new DOMException("Connection aborted","AbortError")),c=()=>{clearTimeout(o),t?.removeEventListener("abort",a)},h=u=>{c(),e.close(),r(u)};t?.addEventListener("abort",a,{once:!0}),e.addEventListener("open",()=>{e.send(JSON.stringify({type:"authenticate",...this.credential()}))},{once:!0}),e.addEventListener("error",()=>h(new f("Could not reach the bridge","unavailable")),{once:!0}),e.addEventListener("close",u=>h(new f(u.reason||"Bridge closed","closed")),{once:!0}),e.addEventListener("message",u=>{const d=X(u.data);d.type==="ready"?(c(),n(d)):d.type==="error"?h(new f(d.message,d.code)):h(new f("Unexpected bridge handshake","invalid_frame"))},{once:!0})});return i.ticket&&this.storeTicket(i.ticket),this.#n=i,e.addEventListener("message",n=>this.#h(n.data)),e.addEventListener("close",n=>this.#c(n.reason||"connection closed")),i}request(t,e={},i=1e4){const n=this.#p("req");return this.#f(n,{type:"request",id:n,method:t,params:e},i)}async openStream(t,e,i,n){const r=this.#p("stream");this.#i.set(r,{onFrame:i,onClose:n});try{await this.#f(r,{type:"stream.open",id:r,method:t,params:e},1e4)}catch(o){throw this.#i.delete(r),o}return{id:r,action:(o,a)=>{if(!this.#i.has(r))return Promise.reject(new f("Terminal stream is closed","stale_stream"));const c=this.#p("action");return this.#f(c,{type:"stream.action",stream_id:r,id:c,action:o,params:a},1e4,r)},close:()=>{this.#i.delete(r),this.#a(r,new f("Terminal stream is closed","stale_stream"));try{this.#l({type:"stream.close",stream_id:r})}catch{}}}}close(t="client closed"){const e=this.#e;this.#e=void 0,this.#n=void 0,e&&e.readyState{const a=setTimeout(()=>{this.#t.delete(t),o(new f("Bridge request timed out","timeout"))},i);this.#t.set(t,{resolve:r,reject:o,timer:a,...n?{streamId:n}:{}});try{this.#l(e)}catch(c){clearTimeout(a),this.#t.delete(t),o(c)}})}#l(t){if(!this.#e||this.#e.readyState!==WebSocket.OPEN||!this.#n)throw new f("Bridge is not connected","disconnected");this.#e.send(JSON.stringify(t))}#h(t){let e;try{e=X(t)}catch{this.close("invalid bridge frame");return}if(e.type==="response"){const i=this.#t.get(e.id);if(!i)return;clearTimeout(i.timer),this.#t.delete(e.id),e.error?i.reject(new f(String(e.error.message??"Request failed"),String(e.error.code??"request_failed"))):i.resolve(e.result);return}if(e.type==="stream.frame"){this.#i.get(e.stream_id)?.onFrame(e.frame);return}if(e.type==="stream.closed"){const i=this.#i.get(e.stream_id);this.#i.delete(e.stream_id),this.#a(e.stream_id,new f(e.reason||"Terminal stream is closed","stale_stream")),i?.onClose(e.reason);return}if(e.type==="devices"){this.dispatchEvent(new CustomEvent("devices",{detail:e.devices}));return}e.type==="error"&&this.dispatchEvent(new CustomEvent("protocol-error",{detail:e}))}#c(t){this.#e&&(this.#e=void 0,this.#n=void 0,this.#m(new f(t,"closed")),this.dispatchEvent(new CustomEvent("disconnected",{detail:t})))}#m(t){for(const e of this.#t.values())clearTimeout(e.timer),e.reject(t);this.#t.clear();for(const e of this.#i.values())e.onClose(t.message);this.#i.clear()}#a(t,e){for(const[i,n]of this.#t)n.streamId===t&&(clearTimeout(n.timer),n.reject(e),this.#t.delete(i))}#p(t){const e=`${t}-${Date.now().toString(36)}-${(++this.#r).toString(36)}`;if(!Mt.test(e))throw new f("Could not create request id");return e}}function X(s){if(typeof s!="string")throw new f("Binary bridge frame rejected","invalid_frame");const t=JSON.parse(s);if(!t||typeof t!="object"||typeof t.type!="string")throw new f("Invalid bridge frame","invalid_frame");return t}const It=["workspace.","tab.","pane.","agent.","task.","automation.","orch."],Ft=12e4,Z=6e4;class Ot extends EventTarget{bridge;#e="disconnected";#t;#i;#r;#n=0;#s=[];#u=!1;#f=!1;#l;#h=!1;#c=!0;#m=0;#a;#p;#_=new Set;#b=0;constructor(t){super(),this.bridge=t,t.addEventListener("disconnected",()=>{this.#c||this.#w()})}get state(){return this.#e}get snapshot(){return this.#t}get capabilities(){return this.#i}get allowedMethods(){return new Set(this.#i?.access?.allowed_methods??this.#i?.methods??[])}async start(){this.#c=!1,this.#m=0;try{await this.#d("connecting")}catch(t){throw this.#e!=="expired"&&this.#w(),t}}stop(){this.#c=!0,this.#a&&clearTimeout(this.#a),this.#a=void 0,this.#p&&clearTimeout(this.#p),this.#p=void 0,this.#_.clear(),this.#b=0,this.#r?.close(),this.#r=void 0,this.bridge.close(),this.#y("disconnected")}async refresh(){if(!this.#u&&!(Date.now(){}),this.#c=!0,this.#a&&clearTimeout(this.#a),this.#a=void 0,this.#p&&clearTimeout(this.#p),this.#p=void 0,this.#_.clear(),this.#b=0,this.#r?.close(),this.#r=void 0,this.#t=void 0,this.#i=void 0,this.#n=0,this.#s=[],this.#y("synchronizing");let e;try{zt(await this.bridge.request("web.sessions.switch",{name:t},Ft),t)}catch(i){e=i}this.bridge.close("session switched"),this.#c=!1,this.#m=0;try{await this.#d("connecting")}catch(i){if(this.#w(),!e)throw i}if(e)throw e}async#d(t){if(!(this.#u||this.#c)){this.#u=!0,this.#y(t);try{await this.bridge.connect(),this.#y("authenticating");const e=Bt(await this.bridge.request("uhp.capabilities")),i=this.#i?.server_generation??this.#t?.server_generation;(i!==void 0&&e.server_generation!==i||this.#n>e.event_sequence)&&(this.#n=0,this.#t=void 0),this.#i=e,this.#y("synchronizing"),this.#s=[],this.#r?.close(),this.#r=await this.bridge.openStream("events.subscribe",this.#n>0?{after_sequence:this.#n}:{},n=>this.#F(n),()=>{this.#c||this.#w()}),await this.#g(),this.#m=0,this.#y("ready")}catch(e){throw e instanceof f&&(e.code==="forbidden"||e.code==="expired")&&this.#y("expired"),e}finally{this.#u=!1}}}async#g(){if(this.#l)return this.#l;const t=this.#v();this.#l=t;try{await t}finally{this.#l=void 0}}async#v(){this.#f=!0;try{const t=Rt(await this.bridge.request("session.snapshot"));if(this.#i&&t.server_generation!==this.#i.server_generation)throw new f("Server generation changed during synchronization","stale_server");this.#t=t,this.#b=0,this.#p&&clearTimeout(this.#p),this.#p=void 0,this.#_.clear(),this.#n=t.event_sequence;const e=this.#s;this.#s=[];for(const i of e)i.sequence>t.event_sequence&&this.#k(i);this.dispatchEvent(new CustomEvent("snapshot",{detail:t}))}catch(t){if(this.#t&&!this.#u){const e=this.#s;this.#s=[];for(const i of e)this.#k(i)}throw t}finally{this.#f=!1}}#F(t){const e=qt(t);if(e){if(e.event==="events.resync_required"){this.#r?.close(),this.#r=void 0,this.#w(!0);return}if(this.#u||this.#f||!this.#t){this.#s.push(e);return}this.#k(e)}}#k(t){if(!(t.sequence<=this.#n)){if(this.#n>0&&t.sequence!==this.#n+1){this.#w(!0);return}this.#n=t.sequence,this.dispatchEvent(new CustomEvent("event",{detail:t})),!(t.event==="agent.title_changed"&&this.#T(t))&&It.some(e=>t.event.startsWith(e))&&this.#S()}}#T(t){const e=t.data;if(!e||typeof e!="object"||Array.isArray(e))return!1;const i=e.pane,n=e.title;if(typeof i!="string"||!(typeof n=="string"||n===null))return!1;for(const r of this.#t?.workspaces??[])for(const o of r.tabs){const a=o.panes.find(c=>c.pane_id===i);if(a)return(a.agent_session_title??null)===n||(a.agent_session_title=n,this.#_.add(i),this.#p||(this.#p=setTimeout(()=>{this.#p=void 0;const c=[...this.#_];this.#_.clear(),!this.#c&&c.length&&this.dispatchEvent(new CustomEvent("titles",{detail:c}))},60))),!0}return!1}#S(){this.#c||(this.#a&&clearTimeout(this.#a),this.#a=setTimeout(()=>{this.#a=void 0,this.refresh().catch(t=>{t instanceof f&&t.code==="rate_limited"||this.#w(!0)})},Math.max(60,this.#b-Date.now())))}async#w(t=!1){if(this.#c||this.#u||this.#h)return;this.#h=!0;let e=t;try{for(;!this.#c&&this.#e!=="expired";){this.#r?.close(),this.#r=void 0,this.bridge.close("reconnecting"),this.#y("reconnecting");const i=e?0:Math.min(1e4,250*2**Math.min(this.#m++,5));if(e=!1,i&&await new Promise(n=>setTimeout(n,i+Math.random()*150)),this.#c)return;try{await this.#d("reconnecting");return}catch(n){n instanceof f&&n.code==="rate_limited"&&await new Promise(r=>setTimeout(r,Z))}}}finally{this.#h=!1}}#y(t){t!==this.#e&&(this.#e=t,this.dispatchEvent(new CustomEvent("state",{detail:t})))}}function Bt(s){if(!s||typeof s!="object"||s.type!=="uhp_capabilities")throw new f("Invalid capabilities response","invalid_response");return s}function Rt(s){if(!s||typeof s!="object"||s.type!=="session_snapshot")throw new f("Invalid snapshot response","invalid_response");return s}function zt(s,t){const e=s;if(e?.type!=="browser_session_switch"||e.session?.name!==t)throw new f("Invalid session switch response","invalid_response")}function qt(s){return typeof s.event=="string"&&Number.isSafeInteger(s.sequence)&&s.sequence>=0?s:void 0}const x="luvus.web.ticket";class Dt{constructor(t=()=>localStorage,e=()=>sessionStorage){this.persistent=t,this.legacy=e;const i=this.#i(this.legacy);if(this.get()===null&&i)this.set(i);else try{this.persistent().getItem(x)!==null&&this.#r(this.legacy)}catch{}}persistent;legacy;#e=null;#t=!1;get(){if(this.#t)return this.#e;try{const t=this.persistent().getItem(x);return this.#e=t,t}catch{}return this.#e=this.#i(this.legacy)??this.#e,this.#e}set(t){this.#e=t;try{this.persistent().setItem(x,t),this.#t=!1,this.#r(this.legacy)}catch{this.#t=!0;try{this.legacy().setItem(x,t)}catch{}}}clear(t){this.get()===t&&(this.#r(this.persistent),this.#r(this.legacy),this.#e=null,this.#t=!1)}#i(t){try{return t().getItem(x)}catch{return null}}#r(t){try{t().removeItem(x)}catch{}}}function l(s,t={},...e){const i=document.createElement(s);t.className&&(i.className=t.className),t.text!==void 0&&(i.textContent=t.text);for(const[n,r]of Object.entries(t.attrs??{}))i.setAttribute(n,r);for(const[n,r]of Object.entries(t.on??{}))i.addEventListener(n,r);for(const n of e)n!==void 0&&i.append(n);return i}function b(s,t,e){return l("button",{className:t,text:s,attrs:{type:"button"},on:{click:e}})}function Wt(s,t){return{...s?{ticket:s}:{},...t?{code:t}:{}}}const Ut=/^[A-Za-z0-9_-]{16,128}$/;function Kt(s){const t=s.trim();if(!t)return;const e=t.indexOf("#"),i=e>=0?new URLSearchParams(t.slice(e+1)).get("pair")??"":t;return Ut.test(i)?i:void 0}function Ht(s){const t="Press Enter in the terminal running luvus web to print a new one-use link, or create one from Devices on a browser that is still connected.";return s.ticket?{title:"This browser's access ended",body:`The web bridge restarted, this device was revoked, or its access expired. ${t}`}:s.code?{title:"This pairing link was already used",body:`Each link pairs one browser and expires after five minutes. Paired browsers can open more tabs without a new link. ${t}`}:{title:"This browser is not paired yet",body:`Pair this browser once to remember access across tabs and browser restarts. ${t}`}}var E=(s=>(s[s.Border=-1]="Border",s[s.Data=0]="Data",s[s.Function=1]="Function",s[s.Position=2]="Position",s[s.Timing=3]="Timing",s[s.Alignment=4]="Alignment",s))(E||{});const Vt=[0,1],ut=[1,0],ft=[2,3],pt=[3,2],jt={L:Vt,M:ut,Q:ft,H:pt},Gt=/^\d*$/,Yt=/^[A-Z0-9 $%*+./:-]*$/,R="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:",H=1,V=40,J=3,Xt=3,$=40,Zt=10,mt=[[-1,7,10,15,20,26,18,20,24,30,18,20,24,26,30,22,24,28,30,28,28,28,28,30,30,26,28,30,30,30,30,30,30,30,30,30,30,30,30,30,30],[-1,10,16,26,18,24,16,18,22,22,26,30,22,22,24,24,28,28,26,26,26,26,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28],[-1,13,22,18,26,18,24,18,22,20,24,28,26,24,20,30,24,28,28,26,30,28,30,30,30,30,28,30,30,30,30,30,30,30,30,30,30,30,30,30,30],[-1,17,28,22,16,22,28,26,26,24,28,24,28,22,24,24,30,28,28,26,28,30,24,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30]],gt=[[-1,1,1,1,1,1,2,2,2,2,4,4,4,4,4,6,6,6,6,7,8,8,9,9,10,12,12,12,13,14,15,16,17,18,19,19,20,21,22,24,25],[-1,1,1,1,2,2,4,4,4,5,5,5,8,9,9,10,10,11,13,14,16,17,17,18,20,21,23,25,26,28,29,31,33,35,37,38,40,43,45,47,49],[-1,1,1,2,2,4,4,6,6,8,8,8,10,12,16,12,17,16,18,21,20,23,23,25,27,29,34,34,35,38,40,43,45,48,51,53,56,59,62,65,68],[-1,1,1,2,4,4,4,5,6,8,8,11,11,16,16,18,16,19,21,25,25,25,34,30,32,35,37,40,42,45,48,51,54,57,60,63,66,70,74,77,81]];class Jt{constructor(t,e,i,n){if(this.version=t,this.ecc=e,tV)throw new RangeError("Version value out of range");if(n<-1||n>7)throw new RangeError("Mask value out of range");this.size=t*4+17;const r=Array.from({length:this.size}).fill(!1);for(let a=0;a0));this.drawFunctionPatterns();const o=this.addEccAndInterleave(i);if(this.drawCodewords(o),n===-1){let a=1e9;for(let c=0;c<8;c++){this.applyMask(c),this.drawFormatBits(c);const h=this.getPenaltyScore();h=0&&t=0&&e>>9)*1335;const n=(e<<10|i)^21522;for(let r=0;r<=5;r++)this.setFunctionModule(8,r,k(n,r));this.setFunctionModule(8,7,k(n,6)),this.setFunctionModule(8,8,k(n,7)),this.setFunctionModule(7,8,k(n,8));for(let r=9;r<15;r++)this.setFunctionModule(14-r,8,k(n,r));for(let r=0;r<8;r++)this.setFunctionModule(this.size-1-r,8,k(n,r));for(let r=8;r<15;r++)this.setFunctionModule(8,this.size-15+r,k(n,r));this.setFunctionModule(8,this.size-8,!0)}drawVersion(){if(this.version<7)return;let t=this.version;for(let i=0;i<12;i++)t=t<<1^(t>>>11)*7973;const e=this.version<<12|t;for(let i=0;i<18;i++){const n=k(e,i),r=this.size-11+i%3,o=Math.floor(i/3);this.setFunctionModule(r,o,n),this.setFunctionModule(o,r,n)}}drawFinderPattern(t,e){for(let i=-4;i<=4;i++)for(let n=-4;n<=4;n++){const r=Math.max(Math.abs(n),Math.abs(i)),o=t+n,a=e+i;o>=0&&o=0&&a{(p!==c-r||v>=a)&&d.push(g[p])});return d}drawCodewords(t){if(t.length!==Math.floor(D(this.version)/8))throw new RangeError("Invalid argument");let e=0;for(let i=this.size-1;i>=1;i-=2){i===6&&(i=5);for(let n=0;n>>3],7-(e&7)),e++)}}}applyMask(t){if(t<0||t>7)throw new RangeError("Mask value out of range");for(let e=0;e5&&t++):(this.finderPenaltyAddHistory(a,c),o||(t+=this.finderPenaltyCountPatterns(c)*$),o=this.modules[r][h],a=1);t+=this.finderPenaltyTerminateAndCount(o,a,c)*$}for(let r=0;r5&&t++):(this.finderPenaltyAddHistory(a,c),o||(t+=this.finderPenaltyCountPatterns(c)*$),o=this.modules[h][r],a=1);t+=this.finderPenaltyTerminateAndCount(o,a,c)*$}for(let r=0;ro+(a?1:0),e);const i=this.size*this.size,n=Math.ceil(Math.abs(e*20-i*10)/i)-1;return t+=n*Zt,t}getAlignmentPatternPositions(){if(this.version===1)return[];{const t=Math.floor(this.version/7)+2,e=this.version===32?26:Math.ceil((this.version*4+4)/(t*2-2))*2,i=[6];for(let n=this.size-7;i.length0&&t[2]===e&&t[3]===e*3&&t[4]===e&&t[5]===e;return(i&&t[0]>=e*4&&t[6]>=e?1:0)+(i&&t[6]>=e*4&&t[0]>=e?1:0)}finderPenaltyTerminateAndCount(t,e,i){return t&&(this.finderPenaltyAddHistory(e,i),e=0),e+=this.size,this.finderPenaltyAddHistory(e,i),this.finderPenaltyCountPatterns(i)}finderPenaltyAddHistory(t,e){e[0]===0&&(t+=this.size),e.pop(),e.unshift(t)}}function S(s,t,e){if(t<0||t>31||s>>>t)throw new RangeError("Value out of range");for(let i=t-1;i>=0;i--)e.push(s>>>i&1)}function k(s,t){return(s>>>t&1)!==0}class j{constructor(t,e,i){if(this.mode=t,this.numChars=e,this.bitData=i,e<0)throw new RangeError("Invalid argument");this.bitData=i.slice()}getData(){return this.bitData.slice()}}const Qt=[1,10,12,14],te=[2,9,11,13],ee=[4,8,16,16];function vt(s,t){return s[Math.floor((t+7)/17)+1]}function bt(s){const t=[];for(const e of s)S(e,8,t);return new j(ee,s.length,t)}function ie(s){if(!wt(s))throw new RangeError("String contains non-numeric characters");const t=[];for(let e=0;e=1<V)throw new RangeError("Version number out of range");let t=(16*s+128)*s+64;if(s>=2){const e=Math.floor(s/7)+2;t-=(25*e-10)*e-55,s>=7&&(t-=36)}return t}function F(s,t){return Math.floor(D(s)/8)-mt[t[0]][s]*gt[t[0]][s]}function oe(s){if(s<1||s>255)throw new RangeError("Degree out of range");const t=[];for(let i=0;i0);for(const i of s){const n=i^e.shift();e.push(0),t.forEach((r,o)=>e[o]^=W(r,n))}return e}function W(s,t){if(s>>>8||t>>>8)throw new RangeError("Byte out of range");let e=0;for(let i=7;i>=0;i--)e=e<<1^(e>>>7)*285,e^=(t>>>i&1)*s;return e}function ce(s,t,e=1,i=40,n=-1,r=!0){if(!(H<=e&&e<=i&&i<=V)||n<-1||n>7)throw new RangeError("Invalid value");let o,a;for(o=e;;o++){const d=F(o,t)*8,p=re(s,o);if(p<=d){a=p;break}if(o>=i)throw new RangeError("Data too long")}for(const d of[ut,ft,pt])r&&a<=F(o,d)*8&&(t=d);const c=[];for(const d of s){S(d.mode[0],4,c),S(d.numChars,vt(d.mode,o),c);for(const p of d.getData())c.push(p)}const h=F(o,t)*8;S(0,Math.min(4,h-c.length),c),S(0,(8-c.length%8)%8,c);for(let d=236;c.length0);return c.forEach((d,p)=>u[p>>>3]|=d<<7-(p&7)),new Jt(o,t,u,n)}function he(s,t){const{ecc:e="L",boostEcc:i=!1,minVersion:n=1,maxVersion:r=40,maskPattern:o=-1,border:a=1}=t||{},c=typeof s=="string"?ne(s):Array.isArray(s)?[bt(s)]:void 0;if(!c)throw new Error(`uqr only supports encoding string and binary data, but got: ${typeof s}`);const h=ce(c,jt[e],n,r,o,i),u=de({version:h.version,maskPattern:h.mask,size:h.size,data:h.modules,types:h.types},a);return t?.invert&&(u.data=u.data.map(d=>d.map(p=>!p))),t?.onEncoded?.(u),u}function de(s,t=1){if(!t)return s;const{size:e}=s,i=e+t*2;s.size=i,s.data.forEach(r=>{for(let o=0;o!1)),s.data.push(Array.from({length:i},o=>!1));const n=E.Border;s.types.forEach(r=>{for(let o=0;on)),s.types.push(Array.from({length:i},o=>n));return s}function Q(s){return s.replace(/&/g,"&").replace(/"/g,""").replace(//g,">")}function ue(s,t={}){const e=he(s,t),{pixelSize:i=10,whiteColor:n="white",blackColor:r="black"}=t,o=e.size*i,a=e.size*i;let c=``;const h=[];for(let u=0;u`,c+=``,c+="",c}function fe(s){const t=ue(s,{ecc:"M",border:3});return`data:image/svg+xml;charset=utf-8,${encodeURIComponent(t)}`}class pe{constructor(t,e=n=>{requestAnimationFrame(()=>n())},i=1e4){this.render=t,this.nextFrame=e,this.maxHoldMs=i}render;nextFrame;maxHoldMs;#e=!1;#t=!1;#i=new Set;#r;get holding(){return this.#i.size>0}request(){this.#e=!0,this.#s()}hold(t){this.#i.add(t),this.#r&&clearTimeout(this.#r),this.#r=setTimeout(()=>this.releaseAll(),this.maxHoldMs)}release(t){this.#i.delete(t)&&(this.holding||this.#n())}releaseAll(){this.holding&&(this.#i.clear(),this.#n())}#n(){this.#r&&clearTimeout(this.#r),this.#r=void 0,this.#e&&this.#s()}#s(){this.#t||this.holding||(this.#t=!0,this.nextFrame(()=>{this.#t=!1,!(this.holding||!this.#e)&&(this.#e=!1,this.render())}))}}function O(s){return l("button",{className:"terminal-workspace-brand",attrs:{type:"button","aria-label":"Luvus overview","data-view-key":"navigation-overview"},on:{click:s}},l("img",{attrs:{src:"/mark.svg",alt:""}}),l("span",{text:"Luvus"}))}function me(s,t,e){return l("button",{className:"terminal-pane-selector",attrs:{type:"button","aria-label":s,title:s,"aria-controls":t},on:{click:e}},l("span",{className:"terminal-pane-dot",attrs:{"aria-hidden":"true"}}))}function kt(s,t){if(s.key!=="Tab")return;const e=Array.from(t.querySelectorAll("button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]")).filter(o=>o.getClientRects().length>0&&o.getAttribute("tabindex")!=="-1"),i=e[0],n=e.at(-1),r=document.activeElement;s.shiftKey&&r===i?(s.preventDefault(),n?.focus()):!s.shiftKey&&r===n&&(s.preventDefault(),i?.focus())}function St(s,t,e){const i=_t([s==="workspaces"?"m14 7-5 5 5 5":"m10 7 5 5-5 5"]);i.classList.add("sidebar-collapse-arrow");const n=l("button",{className:"sidebar-collapse-toggle",attrs:{type:"button","aria-controls":t,"data-view-key":`sidebar-toggle:${s}`},on:{click:e}},i);return G(n,s,!1),n}function G(s,t,e){const i=`${e?"Expand":"Collapse"} ${t} sidebar`;s.setAttribute("aria-label",i),s.setAttribute("aria-expanded",String(!e)),s.title=i}function ge(s,t=!1){return l("button",{className:"mission-nav-button sidebar-refresh",attrs:{type:"button","aria-label":"Refresh live stats",title:"Refresh live stats","data-view-key":"sidebar-refresh",...t?{disabled:""}:{}},on:{click:s}},_t(["M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8","M3 3v5h5"]))}function _t(s){const t=document.createElementNS("http://www.w3.org/2000/svg","svg");t.setAttribute("class","mission-icon"),t.setAttribute("viewBox","0 0 24 24"),t.setAttribute("aria-hidden","true");for(const e of s){const i=document.createElementNS("http://www.w3.org/2000/svg","path");i.setAttribute("d",e),i.setAttribute("fill","none"),i.setAttribute("stroke","currentColor"),i.setAttribute("stroke-width","1.5"),i.setAttribute("stroke-linecap","round"),i.setAttribute("stroke-linejoin","round"),t.append(i)}return t}const ve=["paste_text","upload_start","upload_chunk","upload_finish","upload_cancel"];function be(s){if(!s)return!1;const t=new Set(s);return ve.every(e=>t.has(e))}const we=20,ye=4,ke=500,Se=300;function _e(s,t){return t&&(s?.includes("set_viewport")??!1)}function Ne(s,t,e,i){if(!(s>0)||!(t>0)||!(e>0)||!(i>0))return;const n=Math.min(ke,Math.floor(s/e+.01)),r=Math.min(Se,Math.floor(t/i+.01));if(!(n{const i=w(t.name,`Workspace ${e+1}`);return t.tabs.flatMap((n,r)=>{const o=w(n.name,`Tab ${r+1}`);return n.panes.flatMap((a,c)=>a.kind!=="terminal"||!a.terminal_id?[]:[{pane:a,title:a.is_agent===!0?Ee(a).title:"",agentName:w(a.agent_name,w(a.agent,a.is_agent===!0?"Agent":`Terminal ${c+1}`)),context:`${i} / ${o}`,path:w(a.cwd,w(t.cwd,"Terminal"))}])})})}function tt(s){return s.title?`${s.title} - ${s.agentName}`:s.agentName}function Ce(s,t){return t?s:s.filter(({pane:e})=>e.is_agent===!0)}function Pe(s,t){const e=new Set(t?.tabs.flatMap(i=>i.panes.map(n=>n.pane_id))??[]);return s.filter(({pane:i})=>e.has(i.pane_id))}class Le{#e;select(t,e){if(e.kind!=="terminal"||!e.terminal_id)return;const i=t.workspaces.find(n=>n.tabs.some(r=>r.panes.some(o=>o.kind==="terminal"&&o.pane_id===e.pane_id&&o.terminal_id===e.terminal_id)));i&&(this.#e={session:t.session,generation:t.server_generation,index:i.index,cwd:i.cwd,paneId:e.pane_id,terminalId:e.terminal_id})}resolve(t){const e=this.#e;let i;if(e?.session===t.session&&e.generation===t.server_generation){for(const a of t.workspaces)for(const c of a.tabs){const h=c.panes.find(u=>u.kind==="terminal"&&u.pane_id===e.paneId&&u.terminal_id===e.terminalId);if(h)return e.index=a.index,e.cwd=a.cwd,{workspace:a,pane:h}}i=t.workspaces.find(a=>a.index===e.index&&a.cwd===e.cwd)}if(i||(this.#e=void 0,i=t.workspaces.find(a=>a.active)??t.workspaces[0]),!i)return;const r=(i.tabs.find(a=>a.active)??i.tabs[0])?.panes.filter(a=>a.kind==="terminal"&&a.terminal_id)??[],o=r.find(a=>a.focused)??r[0]??i.tabs.flatMap(a=>a.panes).find(a=>a.kind==="terminal"&&a.terminal_id);return{workspace:i,pane:o}}}function Nt(s,t){return s.workspaces.map((e,i)=>{const n=e.tabs.map((r,o)=>{const a=r.panes.filter(h=>h.kind==="terminal"&&h.terminal_id),c=a.find(h=>h.pane_id===t?.pane_id&&h.terminal_id===t?.terminal_id);return{index:r.index,name:w(r.name,`Tab ${o+1}`),selected:!!c,paneCount:a.length,target:c??a.find(h=>h.focused)??a[0]}});return{index:e.index,name:w(e.name,`Workspace ${i+1}`),branch:w(e.branch,""),path:w(e.cwd,"Path unavailable"),selected:n.some(r=>r.selected),target:n.find(r=>r.selected)?.target??n[e.tabs.findIndex(r=>r.active)]?.target??n.find(r=>r.target)?.target,tabs:n}})}class Me{#e;#t;#i=[];opened(t,e){this.#r(t),!(e.kind!=="terminal"||!e.terminal_id)&&(this.#i=[e.terminal_id,...this.#i.filter(i=>i!==e.terminal_id)].slice(0,128))}options(t){this.#r(t);const e=Ae(t);if(!this.#i.length)return e;const i=new Map(e.map(r=>[r.pane.terminal_id,r]));this.#i=this.#i.filter(r=>i.has(r));const n=[];for(const r of this.#i){const o=i.get(r);o&&n.push(o),i.delete(r)}return[...n,...e.filter(r=>i.has(r.pane.terminal_id))]}#r(t){t.session===this.#e&&t.server_generation===this.#t||(this.#e=t.session,this.#t=t.server_generation,this.#i=[])}}class xt{constructor(t,e,i,n){this.onSelectPane=t,this.#h=e.showShells,this.#e=St("panes",this.root.id,n);const r=l("div",{className:"agent-filters terminal-sidebar-filters",attrs:{role:"group","aria-label":"Filter terminal panes"}});for(const o of[!1,!0]){const a=l("button",{className:"agent-filter",text:o?"All panes":"Active agents",attrs:{type:"button","data-view-key":`pane-filter:${o}`,title:o?"Include shell panes":"Show detected agents, including idle and waiting agents"},on:{click:()=>{this.#h=o,e.onChange(o),this.update(this.#f,this.#l)}}});this.#s.set(o,a),r.append(a)}this.closeButton=l("button",{className:"terminal-pane-selector terminal-sidebar-close",attrs:{type:"button","aria-label":"Close pane list",title:"Close pane list","aria-expanded":"true","aria-controls":"terminal-pane-navigation"},on:{click:i}},l("span",{className:"terminal-pane-dot",attrs:{"aria-hidden":"true"}})),this.#r.append(this.#n),this.root.append(l("header",{className:"terminal-sidebar-heading"},this.#t,this.#i,this.#e),l("div",{className:"terminal-sidebar-toolbar"},r,this.closeButton),this.#r)}onSelectPane;root=l("aside",{className:"terminal-sidebar",attrs:{id:"terminal-pane-navigation","aria-label":"Agents and panes"}});closeButton;#e;#t=l("h2",{text:"Agents"});#i=l("span",{className:"terminal-sidebar-count"});#r=l("nav",{className:"terminal-sidebar-list",attrs:{"aria-label":"Switch terminal pane","data-scroll-key":"pane-sidebar"}});#n=l("p",{className:"terminal-sidebar-empty"});#s=new Map;#u=new Map;#f=[];#l;#h;setCollapsed(t){this.root.classList.toggle("collapsed",t),G(this.#e,"panes",t)}update(t,e,i=this.#h){this.#h=i,this.#f=t,this.#l=e;const n=Ce(t,this.#h);this.#t.textContent=this.#h?"Panes":"Agents",this.#i.textContent=String(n.length),this.#i.setAttribute("aria-label",`${n.length} ${this.#h?"terminal panes":"active agents"}`);for(const[a,c]of this.#s)c.setAttribute("aria-pressed",String(a===this.#h));const r=new Set(n.map(({pane:a})=>a.pane_id));for(const[a,c]of this.#u)r.has(a)||(c.button.remove(),this.#u.delete(a));let o=this.#r.firstChild;for(const a of n){const c=a.pane.pane_id,h=this.#u.get(c)??this.#c(a);h.option=a;const u=a.pane.pane_id===e?.pane_id&&a.pane.terminal_id===e?.terminal_id,d=a.pane.is_agent===!0?w(a.pane.agent_status,"unknown"):"shell",p=Te(d);h.button.className=`terminal-sidebar-pane ${p}${u?" active":""}`,h.button.title=`${tt(a)} · ${d} -${a.context} · ${a.path}`,h.button.setAttribute("aria-label",`${tt(a)}, ${a.context}, ${d}`),u?h.button.setAttribute("aria-current","true"):h.button.removeAttribute("aria-current"),A(h.state,d.toLowerCase()),A(h.title,a.title),h.title.hidden=!a.title,A(h.agent,a.agentName),A(h.context,`${a.title?"| ":""}${a.context}`),h.button!==o&&this.#r.insertBefore(h.button,o),o=h.button.nextSibling}this.#n.hidden=n.length>0,A(this.#n,this.#h?"No terminal panes available.":"No active agents. Choose All panes to show shells.")}#c(t){const e=l("span",{className:"terminal-sidebar-state"}),i=l("span",{className:"terminal-sidebar-title"}),n=l("span",{className:"terminal-sidebar-agent"}),r=l("span",{className:"terminal-sidebar-context"}),o=l("button",{className:"terminal-sidebar-pane",attrs:{type:"button","data-sidebar-pane":t.pane.pane_id,"data-view-key":`sidebar-pane:${t.pane.pane_id}`},on:{click:()=>this.onSelectPane(a.option.pane)}},l("span",{className:"terminal-sidebar-copy"},l("strong",{className:"terminal-sidebar-label"},l("span",{className:"terminal-sidebar-dot",attrs:{"aria-hidden":"true"}}),e,n),l("span",{className:"terminal-sidebar-detail"},i,r))),a={option:t,button:o,state:e,title:i,agent:n,context:r};return this.#u.set(t.pane.pane_id,a),a}}function A(s,t){s.textContent!==t&&(s.textContent=t)}class Et{constructor(t,e,i){this.onSelectPane=t,this.#e=St("workspaces",this.root.id,i),this.#i.append(this.#r),this.root.append(l("div",{className:"terminal-workspace-topbar"},l("button",{className:"terminal-workspace-brand",attrs:{type:"button","aria-label":"Luvus overview","data-view-key":"sidebar-overview"},on:{click:e}},l("img",{attrs:{src:"/mark.svg",alt:""}}),l("span",{text:"Luvus"})),this.#e),l("header",{className:"terminal-sidebar-heading"},l("h2",{text:"Workspaces"}),this.#t),this.#i,this.#n)}onSelectPane;root=l("aside",{className:"terminal-workspace-sidebar",attrs:{id:"terminal-workspace-navigation","aria-label":"Workspaces"}});#e;#t=l("span",{className:"terminal-sidebar-count"});#i=l("nav",{className:"terminal-sidebar-list",attrs:{"aria-label":"Switch workspace","data-scroll-key":"workspace-sidebar"}});#r=l("p",{className:"terminal-sidebar-empty",text:"No workspaces available."});#n=l("footer",{className:"terminal-workspace-footer",attrs:{hidden:""}});#s=new Map;setCollapsed(t){this.root.classList.toggle("collapsed",t),G(this.#e,"workspaces",t)}setFooter(t){this.#n.replaceChildren(...t?[t]:[]),this.#n.hidden=!t}update(t){this.#t.textContent=String(t.length);const e=new Set(t.map(n=>n.index));for(const[n,r]of this.#s)e.has(n)||(r.root.remove(),this.#s.delete(n));let i=this.#i.firstChild;for(const n of t){const r=this.#s.get(n.index)??this.#u(n.index);r.option=n,r.root.classList.toggle("current",n.selected),r.button.disabled=!n.target,r.button.title=`${n.name}${n.branch?` · ${n.branch}`:""} +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const n of document.querySelectorAll('link[rel="modulepreload"]'))i(n);new MutationObserver(n=>{for(const r of n)if(r.type==="childList")for(const o of r.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function e(n){const r={};return n.integrity&&(r.integrity=n.integrity),n.referrerPolicy&&(r.referrerPolicy=n.referrerPolicy),n.crossOrigin==="use-credentials"?r.credentials="include":n.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function i(n){if(n.ep)return;n.ep=!0;const r=e(n);fetch(n.href,r)}})();class p extends Error{code;constructor(t,e="bridge_error"){super(t),this.code=e}}const Rt=/^[A-Za-z0-9._:-]{1,128}$/;class zt extends EventTarget{url;credential;storeTicket;#e;#t=new Map;#i=new Map;#r=0;#n;#s;constructor(t,e,i){super(),this.url=t,this.credential=e,this.storeTicket=i}get ready(){return this.#n}connect(t){return this.#n&&this.#e?.readyState===WebSocket.OPEN?Promise.resolve(this.#n):this.#s?this.#s:(this.#s=this.#a(t).finally(()=>{this.#s=void 0}),this.#s)}async#a(t){this.close("replaced");const e=new WebSocket(this.url);this.#e=e;const i=await new Promise((n,r)=>{const o=setTimeout(()=>h(new p("Bridge connection timed out","timeout")),8e3),a=()=>h(new DOMException("Connection aborted","AbortError")),c=()=>{clearTimeout(o),t?.removeEventListener("abort",a)},h=u=>{c(),e.close(),r(u)};t?.addEventListener("abort",a,{once:!0}),e.addEventListener("open",()=>{e.send(JSON.stringify({type:"authenticate",...this.credential()}))},{once:!0}),e.addEventListener("error",()=>h(new p("Could not reach the bridge","unavailable")),{once:!0}),e.addEventListener("close",u=>h(new p(u.reason||"Bridge closed","closed")),{once:!0}),e.addEventListener("message",u=>{const d=et(u.data);d.type==="ready"?(c(),n(d)):d.type==="error"?h(new p(d.message,d.code)):h(new p("Unexpected bridge handshake","invalid_frame"))},{once:!0})});return i.ticket&&this.storeTicket(i.ticket),this.#n=i,e.addEventListener("message",n=>this.#c(n.data)),e.addEventListener("close",n=>this.#d(n.reason||"connection closed")),i}request(t,e={},i=1e4){const n=this.#p("req");return this.#f(n,{type:"request",id:n,method:t,params:e},i)}async openStream(t,e,i,n){const r=this.#p("stream");this.#i.set(r,{onFrame:i,onClose:n});try{await this.#f(r,{type:"stream.open",id:r,method:t,params:e},1e4)}catch(o){throw this.#i.delete(r),o}return{id:r,action:(o,a)=>{if(!this.#i.has(r))return Promise.reject(new p("Terminal stream is closed","stale_stream"));const c=this.#p("action");return this.#f(c,{type:"stream.action",stream_id:r,id:c,action:o,params:a},1e4,r)},close:()=>{this.#i.delete(r),this.#o(r,new p("Terminal stream is closed","stale_stream"));try{this.#h({type:"stream.close",stream_id:r})}catch{}}}}close(t="client closed"){const e=this.#e;this.#e=void 0,this.#n=void 0,e&&e.readyState{const a=setTimeout(()=>{this.#t.delete(t),o(new p("Bridge request timed out","timeout"))},i);this.#t.set(t,{resolve:r,reject:o,timer:a,...n?{streamId:n}:{}});try{this.#h(e)}catch(c){clearTimeout(a),this.#t.delete(t),o(c)}})}#h(t){if(!this.#e||this.#e.readyState!==WebSocket.OPEN||!this.#n)throw new p("Bridge is not connected","disconnected");this.#e.send(JSON.stringify(t))}#c(t){let e;try{e=et(t)}catch{this.close("invalid bridge frame");return}if(e.type==="response"){const i=this.#t.get(e.id);if(!i)return;clearTimeout(i.timer),this.#t.delete(e.id),e.error?i.reject(new p(String(e.error.message??"Request failed"),String(e.error.code??"request_failed"))):i.resolve(e.result);return}if(e.type==="stream.frame"){this.#i.get(e.stream_id)?.onFrame(e.frame);return}if(e.type==="stream.closed"){const i=this.#i.get(e.stream_id);this.#i.delete(e.stream_id),this.#o(e.stream_id,new p(e.reason||"Terminal stream is closed","stale_stream")),i?.onClose(e.reason);return}if(e.type==="devices"){this.dispatchEvent(new CustomEvent("devices",{detail:e.devices}));return}e.type==="error"&&this.dispatchEvent(new CustomEvent("protocol-error",{detail:e}))}#d(t){this.#e&&(this.#e=void 0,this.#n=void 0,this.#m(new p(t,"closed")),this.dispatchEvent(new CustomEvent("disconnected",{detail:t})))}#m(t){for(const e of this.#t.values())clearTimeout(e.timer),e.reject(t);this.#t.clear();for(const e of this.#i.values())e.onClose(t.message);this.#i.clear()}#o(t,e){for(const[i,n]of this.#t)n.streamId===t&&(clearTimeout(n.timer),n.reject(e),this.#t.delete(i))}#p(t){const e=`${t}-${Date.now().toString(36)}-${(++this.#r).toString(36)}`;if(!Rt.test(e))throw new p("Could not create request id");return e}}function et(s){if(typeof s!="string")throw new p("Binary bridge frame rejected","invalid_frame");const t=JSON.parse(s);if(!t||typeof t!="object"||typeof t.type!="string")throw new p("Invalid bridge frame","invalid_frame");return t}const Dt=["workspace.","tab.","pane.","agent.","task.","automation.","orch."],qt=12e4,it=6e4;class Kt extends EventTarget{bridge;#e="disconnected";#t;#i;#r;#n=0;#s=[];#a=!1;#f=!1;#h;#c=!1;#d=!0;#m=0;#o;#p;#S=new Set;#b=0;constructor(t){super(),this.bridge=t,t.addEventListener("disconnected",()=>{this.#d||this.#g()})}get state(){return this.#e}get snapshot(){return this.#t}get capabilities(){return this.#i}get allowedMethods(){return new Set(this.#i?.access?.allowed_methods??this.#i?.methods??[])}async start(){this.#d=!1,this.#m=0;try{await this.#l("connecting")}catch(t){throw this.#e!=="expired"&&this.#g(),t}}stop(){this.#d=!0,this.#o&&clearTimeout(this.#o),this.#o=void 0,this.#p&&clearTimeout(this.#p),this.#p=void 0,this.#S.clear(),this.#b=0,this.#r?.close(),this.#r=void 0,this.bridge.close(),this.#y("disconnected")}async refresh(){if(!this.#a&&!(Date.now(){}),this.#d=!0,this.#o&&clearTimeout(this.#o),this.#o=void 0,this.#p&&clearTimeout(this.#p),this.#p=void 0,this.#S.clear(),this.#b=0,this.#r?.close(),this.#r=void 0,this.#t=void 0,this.#i=void 0,this.#n=0,this.#s=[],this.#y("synchronizing");let e;try{Ht(await this.bridge.request("web.sessions.switch",{name:t},qt),t)}catch(i){e=i}this.bridge.close("session switched"),this.#d=!1,this.#m=0;try{await this.#l("connecting")}catch(i){if(this.#g(),!e)throw i}if(e)throw e}async#l(t){if(!(this.#a||this.#d)){this.#a=!0,this.#y(t);try{await this.bridge.connect(),this.#y("authenticating");const e=Ut(await this.bridge.request("uhp.capabilities")),i=this.#i?.server_generation??this.#t?.server_generation;(i!==void 0&&e.server_generation!==i||this.#n>e.event_sequence)&&(this.#n=0,this.#t=void 0),this.#i=e,this.#y("synchronizing"),this.#s=[],this.#r?.close(),this.#r=await this.bridge.openStream("events.subscribe",this.#n>0?{after_sequence:this.#n}:{},n=>this.#w(n),()=>{this.#d||this.#g()}),await this.#N(),this.#m=0,this.#y("ready")}catch(e){throw e instanceof p&&(e.code==="forbidden"||e.code==="expired")&&this.#y("expired"),e}finally{this.#a=!1}}}async#N(){if(this.#h)return this.#h;const t=this.#v();this.#h=t;try{await t}finally{this.#h=void 0}}async#v(){this.#f=!0;try{const t=Wt(await this.bridge.request("session.snapshot"));if(this.#i&&t.server_generation!==this.#i.server_generation)throw new p("Server generation changed during synchronization","stale_server");this.#t=t,this.#b=0,this.#p&&clearTimeout(this.#p),this.#p=void 0,this.#S.clear(),this.#n=t.event_sequence;const e=this.#s;this.#s=[];for(const i of e)i.sequence>t.event_sequence&&this.#k(i);this.dispatchEvent(new CustomEvent("snapshot",{detail:t}))}catch(t){if(this.#t&&!this.#a){const e=this.#s;this.#s=[];for(const i of e)this.#k(i)}throw t}finally{this.#f=!1}}#w(t){const e=Vt(t);if(e){if(e.event==="events.resync_required"){this.#r?.close(),this.#r=void 0,this.#g(!0);return}if(this.#a||this.#f||!this.#t){this.#s.push(e);return}this.#k(e)}}#k(t){if(!(t.sequence<=this.#n)){if(this.#n>0&&t.sequence!==this.#n+1){this.#g(!0);return}this.#n=t.sequence,this.dispatchEvent(new CustomEvent("event",{detail:t})),!(t.event==="agent.title_changed"&&this.#z(t))&&Dt.some(e=>t.event.startsWith(e))&&this.#_()}}#z(t){const e=t.data;if(!e||typeof e!="object"||Array.isArray(e))return!1;const i=e.pane,n=e.title;if(typeof i!="string"||!(typeof n=="string"||n===null))return!1;for(const r of this.#t?.workspaces??[])for(const o of r.tabs){const a=o.panes.find(c=>c.pane_id===i);if(a)return(a.agent_session_title??null)===n||(a.agent_session_title=n,this.#S.add(i),this.#p||(this.#p=setTimeout(()=>{this.#p=void 0;const c=[...this.#S];this.#S.clear(),!this.#d&&c.length&&this.dispatchEvent(new CustomEvent("titles",{detail:c}))},60))),!0}return!1}#_(){this.#d||(this.#o&&clearTimeout(this.#o),this.#o=setTimeout(()=>{this.#o=void 0,this.refresh().catch(t=>{t instanceof p&&t.code==="rate_limited"||this.#g(!0)})},Math.max(60,this.#b-Date.now())))}async#g(t=!1){if(this.#d||this.#a||this.#c)return;this.#c=!0;let e=t;try{for(;!this.#d&&this.#e!=="expired";){this.#r?.close(),this.#r=void 0,this.bridge.close("reconnecting"),this.#y("reconnecting");const i=e?0:Math.min(1e4,250*2**Math.min(this.#m++,5));if(e=!1,i&&await new Promise(n=>setTimeout(n,i+Math.random()*150)),this.#d)return;try{await this.#l("reconnecting");return}catch(n){n instanceof p&&n.code==="rate_limited"&&await new Promise(r=>setTimeout(r,it))}}}finally{this.#c=!1}}#y(t){t!==this.#e&&(this.#e=t,this.dispatchEvent(new CustomEvent("state",{detail:t})))}}function Ut(s){if(!s||typeof s!="object"||s.type!=="uhp_capabilities")throw new p("Invalid capabilities response","invalid_response");return s}function Wt(s){if(!s||typeof s!="object"||s.type!=="session_snapshot")throw new p("Invalid snapshot response","invalid_response");return s}function Ht(s,t){const e=s;if(e?.type!=="browser_session_switch"||e.session?.name!==t)throw new p("Invalid session switch response","invalid_response")}function Vt(s){return typeof s.event=="string"&&Number.isSafeInteger(s.sequence)&&s.sequence>=0?s:void 0}const E="luvus.web.ticket";class jt{constructor(t=()=>localStorage,e=()=>sessionStorage){this.persistent=t,this.legacy=e;const i=this.#i(this.legacy);if(this.get()===null&&i)this.set(i);else try{this.persistent().getItem(E)!==null&&this.#r(this.legacy)}catch{}}persistent;legacy;#e=null;#t=!1;get(){if(this.#t)return this.#e;try{const t=this.persistent().getItem(E);return this.#e=t,t}catch{}return this.#e=this.#i(this.legacy)??this.#e,this.#e}set(t){this.#e=t;try{this.persistent().setItem(E,t),this.#t=!1,this.#r(this.legacy)}catch{this.#t=!0;try{this.legacy().setItem(E,t)}catch{}}}clear(t){this.get()===t&&(this.#r(this.persistent),this.#r(this.legacy),this.#e=null,this.#t=!1)}#i(t){try{return t().getItem(E)}catch{return null}}#r(t){try{t().removeItem(E)}catch{}}}function l(s,t={},...e){const i=document.createElement(s);t.className&&(i.className=t.className),t.text!==void 0&&(i.textContent=t.text);for(const[n,r]of Object.entries(t.attrs??{}))i.setAttribute(n,r);for(const[n,r]of Object.entries(t.on??{}))i.addEventListener(n,r);for(const n of e)n!==void 0&&i.append(n);return i}function y(s,t,e){return l("button",{className:t,text:s,attrs:{type:"button"},on:{click:e}})}function Gt(s,t){return{...s?{ticket:s}:{},...t?{code:t}:{}}}const Yt=/^[A-Za-z0-9_-]{16,128}$/;function Xt(s){const t=s.trim();if(!t)return;const e=t.indexOf("#"),i=e>=0?new URLSearchParams(t.slice(e+1)).get("pair")??"":t;return Yt.test(i)?i:void 0}function Zt(s){const t="Press Enter in the terminal running luvus web to print a new one-use link, or create one from Devices on a browser that is still connected.";return s.ticket?{title:"This browser's access ended",body:`The web bridge restarted, this device was revoked, or its access expired. ${t}`}:s.code?{title:"This pairing link was already used",body:`Each link pairs one browser and expires after five minutes. Paired browsers can open more tabs without a new link. ${t}`}:{title:"This browser is not paired yet",body:`Pair this browser once to remember access across tabs and browser restarts. ${t}`}}var A=(s=>(s[s.Border=-1]="Border",s[s.Data=0]="Data",s[s.Function=1]="Function",s[s.Position=2]="Position",s[s.Timing=3]="Timing",s[s.Alignment=4]="Alignment",s))(A||{});const Jt=[0,1],wt=[1,0],yt=[2,3],kt=[3,2],Qt={L:Jt,M:wt,Q:yt,H:kt},te=/^\d*$/,ee=/^[A-Z0-9 $%*+./:-]*$/,q="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:",X=1,Z=40,st=3,ie=3,I=40,se=10,St=[[-1,7,10,15,20,26,18,20,24,30,18,20,24,26,30,22,24,28,30,28,28,28,28,30,30,26,28,30,30,30,30,30,30,30,30,30,30,30,30,30,30],[-1,10,16,26,18,24,16,18,22,22,26,30,22,22,24,24,28,28,26,26,26,26,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28],[-1,13,22,18,26,18,24,18,22,20,24,28,26,24,20,30,24,28,28,26,30,28,30,30,30,30,28,30,30,30,30,30,30,30,30,30,30,30,30,30,30],[-1,17,28,22,16,22,28,26,26,24,28,24,28,22,24,24,30,28,28,26,28,30,24,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30]],_t=[[-1,1,1,1,1,1,2,2,2,2,4,4,4,4,4,6,6,6,6,7,8,8,9,9,10,12,12,12,13,14,15,16,17,18,19,19,20,21,22,24,25],[-1,1,1,1,2,2,4,4,4,5,5,5,8,9,9,10,10,11,13,14,16,17,17,18,20,21,23,25,26,28,29,31,33,35,37,38,40,43,45,47,49],[-1,1,1,2,2,4,4,6,6,8,8,8,10,12,16,12,17,16,18,21,20,23,23,25,27,29,34,34,35,38,40,43,45,48,51,53,56,59,62,65,68],[-1,1,1,2,4,4,4,5,6,8,8,11,11,16,16,18,16,19,21,25,25,25,34,30,32,35,37,40,42,45,48,51,54,57,60,63,66,70,74,77,81]];class ne{constructor(t,e,i,n){if(this.version=t,this.ecc=e,tZ)throw new RangeError("Version value out of range");if(n<-1||n>7)throw new RangeError("Mask value out of range");this.size=t*4+17;const r=Array.from({length:this.size}).fill(!1);for(let a=0;a0));this.drawFunctionPatterns();const o=this.addEccAndInterleave(i);if(this.drawCodewords(o),n===-1){let a=1e9;for(let c=0;c<8;c++){this.applyMask(c),this.drawFormatBits(c);const h=this.getPenaltyScore();h=0&&t=0&&e>>9)*1335;const n=(e<<10|i)^21522;for(let r=0;r<=5;r++)this.setFunctionModule(8,r,_(n,r));this.setFunctionModule(8,7,_(n,6)),this.setFunctionModule(8,8,_(n,7)),this.setFunctionModule(7,8,_(n,8));for(let r=9;r<15;r++)this.setFunctionModule(14-r,8,_(n,r));for(let r=0;r<8;r++)this.setFunctionModule(this.size-1-r,8,_(n,r));for(let r=8;r<15;r++)this.setFunctionModule(8,this.size-15+r,_(n,r));this.setFunctionModule(8,this.size-8,!0)}drawVersion(){if(this.version<7)return;let t=this.version;for(let i=0;i<12;i++)t=t<<1^(t>>>11)*7973;const e=this.version<<12|t;for(let i=0;i<18;i++){const n=_(e,i),r=this.size-11+i%3,o=Math.floor(i/3);this.setFunctionModule(r,o,n),this.setFunctionModule(o,r,n)}}drawFinderPattern(t,e){for(let i=-4;i<=4;i++)for(let n=-4;n<=4;n++){const r=Math.max(Math.abs(n),Math.abs(i)),o=t+n,a=e+i;o>=0&&o=0&&a{(m!==c-r||g>=a)&&d.push(b[m])});return d}drawCodewords(t){if(t.length!==Math.floor(H(this.version)/8))throw new RangeError("Invalid argument");let e=0;for(let i=this.size-1;i>=1;i-=2){i===6&&(i=5);for(let n=0;n>>3],7-(e&7)),e++)}}}applyMask(t){if(t<0||t>7)throw new RangeError("Mask value out of range");for(let e=0;e5&&t++):(this.finderPenaltyAddHistory(a,c),o||(t+=this.finderPenaltyCountPatterns(c)*I),o=this.modules[r][h],a=1);t+=this.finderPenaltyTerminateAndCount(o,a,c)*I}for(let r=0;r5&&t++):(this.finderPenaltyAddHistory(a,c),o||(t+=this.finderPenaltyCountPatterns(c)*I),o=this.modules[h][r],a=1);t+=this.finderPenaltyTerminateAndCount(o,a,c)*I}for(let r=0;ro+(a?1:0),e);const i=this.size*this.size,n=Math.ceil(Math.abs(e*20-i*10)/i)-1;return t+=n*se,t}getAlignmentPatternPositions(){if(this.version===1)return[];{const t=Math.floor(this.version/7)+2,e=this.version===32?26:Math.ceil((this.version*4+4)/(t*2-2))*2,i=[6];for(let n=this.size-7;i.length0&&t[2]===e&&t[3]===e*3&&t[4]===e&&t[5]===e;return(i&&t[0]>=e*4&&t[6]>=e?1:0)+(i&&t[6]>=e*4&&t[0]>=e?1:0)}finderPenaltyTerminateAndCount(t,e,i){return t&&(this.finderPenaltyAddHistory(e,i),e=0),e+=this.size,this.finderPenaltyAddHistory(e,i),this.finderPenaltyCountPatterns(i)}finderPenaltyAddHistory(t,e){e[0]===0&&(t+=this.size),e.pop(),e.unshift(t)}}function N(s,t,e){if(t<0||t>31||s>>>t)throw new RangeError("Value out of range");for(let i=t-1;i>=0;i--)e.push(s>>>i&1)}function _(s,t){return(s>>>t&1)!==0}class J{constructor(t,e,i){if(this.mode=t,this.numChars=e,this.bitData=i,e<0)throw new RangeError("Invalid argument");this.bitData=i.slice()}getData(){return this.bitData.slice()}}const re=[1,10,12,14],ae=[2,9,11,13],oe=[4,8,16,16];function Nt(s,t){return s[Math.floor((t+7)/17)+1]}function xt(s){const t=[];for(const e of s)N(e,8,t);return new J(oe,s.length,t)}function le(s){if(!Tt(s))throw new RangeError("String contains non-numeric characters");const t=[];for(let e=0;e=1<Z)throw new RangeError("Version number out of range");let t=(16*s+128)*s+64;if(s>=2){const e=Math.floor(s/7)+2;t-=(25*e-10)*e-55,s>=7&&(t-=36)}return t}function B(s,t){return Math.floor(H(s)/8)-St[t[0]][s]*_t[t[0]][s]}function fe(s){if(s<1||s>255)throw new RangeError("Degree out of range");const t=[];for(let i=0;i0);for(const i of s){const n=i^e.shift();e.push(0),t.forEach((r,o)=>e[o]^=V(r,n))}return e}function V(s,t){if(s>>>8||t>>>8)throw new RangeError("Byte out of range");let e=0;for(let i=7;i>=0;i--)e=e<<1^(e>>>7)*285,e^=(t>>>i&1)*s;return e}function me(s,t,e=1,i=40,n=-1,r=!0){if(!(X<=e&&e<=i&&i<=Z)||n<-1||n>7)throw new RangeError("Invalid value");let o,a;for(o=e;;o++){const d=B(o,t)*8,m=de(s,o);if(m<=d){a=m;break}if(o>=i)throw new RangeError("Data too long")}for(const d of[wt,yt,kt])r&&a<=B(o,d)*8&&(t=d);const c=[];for(const d of s){N(d.mode[0],4,c),N(d.numChars,Nt(d.mode,o),c);for(const m of d.getData())c.push(m)}const h=B(o,t)*8;N(0,Math.min(4,h-c.length),c),N(0,(8-c.length%8)%8,c);for(let d=236;c.length0);return c.forEach((d,m)=>u[m>>>3]|=d<<7-(m&7)),new ne(o,t,u,n)}function ge(s,t){const{ecc:e="L",boostEcc:i=!1,minVersion:n=1,maxVersion:r=40,maskPattern:o=-1,border:a=1}=t||{},c=typeof s=="string"?he(s):Array.isArray(s)?[xt(s)]:void 0;if(!c)throw new Error(`uqr only supports encoding string and binary data, but got: ${typeof s}`);const h=me(c,Qt[e],n,r,o,i),u=be({version:h.version,maskPattern:h.mask,size:h.size,data:h.modules,types:h.types},a);return t?.invert&&(u.data=u.data.map(d=>d.map(m=>!m))),t?.onEncoded?.(u),u}function be(s,t=1){if(!t)return s;const{size:e}=s,i=e+t*2;s.size=i,s.data.forEach(r=>{for(let o=0;o!1)),s.data.push(Array.from({length:i},o=>!1));const n=A.Border;s.types.forEach(r=>{for(let o=0;on)),s.types.push(Array.from({length:i},o=>n));return s}function nt(s){return s.replace(/&/g,"&").replace(/"/g,""").replace(//g,">")}function ve(s,t={}){const e=ge(s,t),{pixelSize:i=10,whiteColor:n="white",blackColor:r="black"}=t,o=e.size*i,a=e.size*i;let c=``;const h=[];for(let u=0;u`,c+=``,c+="",c}function we(s){const t=ve(s,{ecc:"M",border:3});return`data:image/svg+xml;charset=utf-8,${encodeURIComponent(t)}`}class ye{constructor(t,e=n=>{requestAnimationFrame(()=>n())},i=1e4){this.render=t,this.nextFrame=e,this.maxHoldMs=i}render;nextFrame;maxHoldMs;#e=!1;#t=!1;#i=new Set;#r;get holding(){return this.#i.size>0}request(){this.#e=!0,this.#s()}hold(t){this.#i.add(t),this.#r&&clearTimeout(this.#r),this.#r=setTimeout(()=>this.releaseAll(),this.maxHoldMs)}release(t){this.#i.delete(t)&&(this.holding||this.#n())}releaseAll(){this.holding&&(this.#i.clear(),this.#n())}#n(){this.#r&&clearTimeout(this.#r),this.#r=void 0,this.#e&&this.#s()}#s(){this.#t||this.holding||(this.#t=!0,this.nextFrame(()=>{this.#t=!1,!(this.holding||!this.#e)&&(this.#e=!1,this.render())}))}}function R(s){return l("button",{className:"terminal-workspace-brand",attrs:{type:"button","aria-label":"Luvus overview","data-view-key":"navigation-overview"},on:{click:s}},l("img",{attrs:{src:"/mark.svg",alt:""}}),l("span",{text:"Luvus"}))}function ke(s,t,e){return l("button",{className:"terminal-pane-selector",attrs:{type:"button","aria-label":s,title:s,"aria-controls":t},on:{click:e}},l("span",{className:"terminal-pane-dot",attrs:{"aria-hidden":"true"}}))}function At(s,t){if(s.key!=="Tab")return;const e=Array.from(t.querySelectorAll("button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]")).filter(o=>o.getClientRects().length>0&&o.getAttribute("tabindex")!=="-1"),i=e[0],n=e.at(-1),r=document.activeElement;s.shiftKey&&r===i?(s.preventDefault(),n?.focus()):!s.shiftKey&&r===n&&(s.preventDefault(),i?.focus())}function Ct(s,t,e){const i=Mt([s==="workspaces"?"m14 7-5 5 5 5":"m10 7 5 5-5 5"]);i.classList.add("sidebar-collapse-arrow");const n=l("button",{className:"sidebar-collapse-toggle",attrs:{type:"button","aria-controls":t,"data-view-key":`sidebar-toggle:${s}`},on:{click:e}},i);return Q(n,s,!1),n}function Q(s,t,e){const i=`${e?"Expand":"Collapse"} ${t} sidebar`;s.setAttribute("aria-label",i),s.setAttribute("aria-expanded",String(!e)),s.title=i}function Se(s,t=!1){return l("button",{className:"mission-nav-button sidebar-refresh",attrs:{type:"button","aria-label":"Refresh live stats",title:"Refresh live stats","data-view-key":"sidebar-refresh",...t?{disabled:""}:{}},on:{click:s}},Mt(["M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8","M3 3v5h5"]))}function Mt(s){const t=document.createElementNS("http://www.w3.org/2000/svg","svg");t.setAttribute("class","mission-icon"),t.setAttribute("viewBox","0 0 24 24"),t.setAttribute("aria-hidden","true");for(const e of s){const i=document.createElementNS("http://www.w3.org/2000/svg","path");i.setAttribute("d",e),i.setAttribute("fill","none"),i.setAttribute("stroke","currentColor"),i.setAttribute("stroke-width","1.5"),i.setAttribute("stroke-linecap","round"),i.setAttribute("stroke-linejoin","round"),t.append(i)}return t}const _e=["paste_text","upload_start","upload_chunk","upload_finish","upload_cancel"];function Ne(s){if(!s)return!1;const t=new Set(s);return _e.every(e=>t.has(e))}const xe=20,Te=4,Ee=500,Ae=300;function Ce(s,t){return t&&(s?.includes("set_viewport")??!1)}function Me(s,t,e,i){if(!(s>0)||!(t>0)||!(e>0)||!(i>0))return;const n=Math.min(Ee,Math.floor(s/e+.01)),r=Math.min(Ae,Math.floor(t/i+.01));if(!(n{const i=k(t.name,`Workspace ${e+1}`);return t.tabs.flatMap((n,r)=>{const o=k(n.name,`Tab ${r+1}`);return n.panes.flatMap((a,c)=>a.kind!=="terminal"||!a.terminal_id?[]:[{pane:a,title:a.is_agent===!0?Pe(a).title:"",agentName:k(a.agent_name,k(a.agent,a.is_agent===!0?"Agent":`Terminal ${c+1}`)),context:`${i} / ${o}`,path:k(a.cwd,k(t.cwd,"Terminal"))}])})})}function rt(s){return s.title?`${s.title} - ${s.agentName}`:s.agentName}function Fe(s,t){return t?s:s.filter(({pane:e})=>e.is_agent===!0)}function Oe(s,t){const e=new Set(t?.tabs.flatMap(i=>i.panes.map(n=>n.pane_id))??[]);return s.filter(({pane:i})=>e.has(i.pane_id))}class Be{#e;select(t,e){if(e.kind!=="terminal"||!e.terminal_id)return;const i=t.workspaces.find(n=>n.tabs.some(r=>r.panes.some(o=>o.kind==="terminal"&&o.pane_id===e.pane_id&&o.terminal_id===e.terminal_id)));i&&(this.#e={session:t.session,generation:t.server_generation,index:i.index,cwd:i.cwd,paneId:e.pane_id,terminalId:e.terminal_id})}resolve(t){const e=this.#e;let i;if(e?.session===t.session&&e.generation===t.server_generation){for(const a of t.workspaces)for(const c of a.tabs){const h=c.panes.find(u=>u.kind==="terminal"&&u.pane_id===e.paneId&&u.terminal_id===e.terminalId);if(h)return e.index=a.index,e.cwd=a.cwd,{workspace:a,pane:h}}i=t.workspaces.find(a=>a.index===e.index&&a.cwd===e.cwd)}if(i||(this.#e=void 0,i=t.workspaces.find(a=>a.active)??t.workspaces[0]),!i)return;const r=(i.tabs.find(a=>a.active)??i.tabs[0])?.panes.filter(a=>a.kind==="terminal"&&a.terminal_id)??[],o=r.find(a=>a.focused)??r[0]??i.tabs.flatMap(a=>a.panes).find(a=>a.kind==="terminal"&&a.terminal_id);return{workspace:i,pane:o}}}function Lt(s,t){return s.workspaces.map((e,i)=>{const n=e.tabs.map((r,o)=>{const a=r.panes.filter(h=>h.kind==="terminal"&&h.terminal_id),c=a.find(h=>h.pane_id===t?.pane_id&&h.terminal_id===t?.terminal_id);return{index:r.index,name:k(r.name,`Tab ${o+1}`),selected:!!c,paneCount:a.length,target:c??a.find(h=>h.focused)??a[0]}});return{index:e.index,name:k(e.name,`Workspace ${i+1}`),branch:k(e.branch,""),path:k(e.cwd,"Path unavailable"),selected:n.some(r=>r.selected),target:n.find(r=>r.selected)?.target??n[e.tabs.findIndex(r=>r.active)]?.target??n.find(r=>r.target)?.target,tabs:n}})}class Re{#e;#t;#i=[];opened(t,e){this.#r(t),!(e.kind!=="terminal"||!e.terminal_id)&&(this.#i=[e.terminal_id,...this.#i.filter(i=>i!==e.terminal_id)].slice(0,128))}options(t){this.#r(t);const e=Ie(t);if(!this.#i.length)return e;const i=new Map(e.map(r=>[r.pane.terminal_id,r]));this.#i=this.#i.filter(r=>i.has(r));const n=[];for(const r of this.#i){const o=i.get(r);o&&n.push(o),i.delete(r)}return[...n,...e.filter(r=>i.has(r.pane.terminal_id))]}#r(t){t.session===this.#e&&t.server_generation===this.#t||(this.#e=t.session,this.#t=t.server_generation,this.#i=[])}}class Pt{constructor(t,e,i,n){this.onSelectPane=t,this.#c=e.showShells,this.#e=Ct("panes",this.root.id,n);const r=l("div",{className:"agent-filters terminal-sidebar-filters",attrs:{role:"group","aria-label":"Filter terminal panes"}});for(const o of[!1,!0]){const a=l("button",{className:"agent-filter",text:o?"All panes":"Active agents",attrs:{type:"button","data-view-key":`pane-filter:${o}`,title:o?"Include shell panes":"Show detected agents, including idle and waiting agents"},on:{click:()=>{this.#c=o,e.onChange(o),this.update(this.#f,this.#h)}}});this.#s.set(o,a),r.append(a)}this.closeButton=l("button",{className:"terminal-pane-selector terminal-sidebar-close",attrs:{type:"button","aria-label":"Close pane list",title:"Close pane list","aria-expanded":"true","aria-controls":"terminal-pane-navigation"},on:{click:i}},l("span",{className:"terminal-pane-dot",attrs:{"aria-hidden":"true"}})),this.#r.append(this.#n),this.root.append(l("header",{className:"terminal-sidebar-heading"},this.#t,this.#i,this.#e),l("div",{className:"terminal-sidebar-toolbar"},r,this.closeButton),this.#r)}onSelectPane;root=l("aside",{className:"terminal-sidebar",attrs:{id:"terminal-pane-navigation","aria-label":"Agents and panes"}});closeButton;#e;#t=l("h2",{text:"Agents"});#i=l("span",{className:"terminal-sidebar-count"});#r=l("nav",{className:"terminal-sidebar-list",attrs:{"aria-label":"Switch terminal pane","data-scroll-key":"pane-sidebar"}});#n=l("p",{className:"terminal-sidebar-empty"});#s=new Map;#a=new Map;#f=[];#h;#c;setCollapsed(t){this.root.classList.toggle("collapsed",t),Q(this.#e,"panes",t)}update(t,e,i=this.#c){this.#c=i,this.#f=t,this.#h=e;const n=Fe(t,this.#c);this.#t.textContent=this.#c?"Panes":"Agents",this.#i.textContent=String(n.length),this.#i.setAttribute("aria-label",`${n.length} ${this.#c?"terminal panes":"active agents"}`);for(const[a,c]of this.#s)c.setAttribute("aria-pressed",String(a===this.#c));const r=new Set(n.map(({pane:a})=>a.pane_id));for(const[a,c]of this.#a)r.has(a)||(c.button.remove(),this.#a.delete(a));let o=this.#r.firstChild;for(const a of n){const c=a.pane.pane_id,h=this.#a.get(c)??this.#d(a);h.option=a;const u=a.pane.pane_id===e?.pane_id&&a.pane.terminal_id===e?.terminal_id,d=a.pane.is_agent===!0?k(a.pane.agent_status,"unknown"):"shell",m=$e(d);h.button.className=`terminal-sidebar-pane ${m}${u?" active":""}`,h.button.title=`${rt(a)} · ${d} +${a.context} · ${a.path}`,h.button.setAttribute("aria-label",`${rt(a)}, ${a.context}, ${d}`),u?h.button.setAttribute("aria-current","true"):h.button.removeAttribute("aria-current"),L(h.state,d.toLowerCase()),L(h.title,a.title),h.title.hidden=!a.title,L(h.agent,a.agentName),L(h.context,`${a.title?"| ":""}${a.context}`),h.button!==o&&this.#r.insertBefore(h.button,o),o=h.button.nextSibling}this.#n.hidden=n.length>0,L(this.#n,this.#c?"No terminal panes available.":"No active agents. Choose All panes to show shells.")}#d(t){const e=l("span",{className:"terminal-sidebar-state"}),i=l("span",{className:"terminal-sidebar-title"}),n=l("span",{className:"terminal-sidebar-agent"}),r=l("span",{className:"terminal-sidebar-context"}),o=l("button",{className:"terminal-sidebar-pane",attrs:{type:"button","data-sidebar-pane":t.pane.pane_id,"data-view-key":`sidebar-pane:${t.pane.pane_id}`},on:{click:()=>this.onSelectPane(a.option.pane)}},l("span",{className:"terminal-sidebar-copy"},l("strong",{className:"terminal-sidebar-label"},l("span",{className:"terminal-sidebar-dot",attrs:{"aria-hidden":"true"}}),e,n),l("span",{className:"terminal-sidebar-detail"},i,r))),a={option:t,button:o,state:e,title:i,agent:n,context:r};return this.#a.set(t.pane.pane_id,a),a}}function L(s,t){s.textContent!==t&&(s.textContent=t)}class $t{constructor(t,e,i){this.onSelectPane=t,this.#e=Ct("workspaces",this.root.id,i),this.#i.append(this.#r),this.root.append(l("div",{className:"terminal-workspace-topbar"},l("button",{className:"terminal-workspace-brand",attrs:{type:"button","aria-label":"Luvus overview","data-view-key":"sidebar-overview"},on:{click:e}},l("img",{attrs:{src:"/mark.svg",alt:""}}),l("span",{text:"Luvus"})),this.#e),l("header",{className:"terminal-sidebar-heading"},l("h2",{text:"Workspaces"}),this.#t),this.#i,this.#n)}onSelectPane;root=l("aside",{className:"terminal-workspace-sidebar",attrs:{id:"terminal-workspace-navigation","aria-label":"Workspaces"}});#e;#t=l("span",{className:"terminal-sidebar-count"});#i=l("nav",{className:"terminal-sidebar-list",attrs:{"aria-label":"Switch workspace","data-scroll-key":"workspace-sidebar"}});#r=l("p",{className:"terminal-sidebar-empty",text:"No workspaces available."});#n=l("footer",{className:"terminal-workspace-footer",attrs:{hidden:""}});#s=new Map;setCollapsed(t){this.root.classList.toggle("collapsed",t),Q(this.#e,"workspaces",t)}setFooter(t){this.#n.replaceChildren(...t?[t]:[]),this.#n.hidden=!t}update(t){this.#t.textContent=String(t.length);const e=new Set(t.map(n=>n.index));for(const[n,r]of this.#s)e.has(n)||(r.root.remove(),this.#s.delete(n));let i=this.#i.firstChild;for(const n of t){const r=this.#s.get(n.index)??this.#a(n.index);r.option=n,r.root.classList.toggle("current",n.selected),r.button.disabled=!n.target,r.button.title=`${n.name}${n.branch?` · ${n.branch}`:""} ${n.path}${n.target?"":` -No live terminal panes in this workspace`}`,r.button.setAttribute("aria-label",`Open workspace ${n.name}${n.branch?`, branch ${n.branch}`:""}, ${n.path}`),n.selected?r.button.setAttribute("aria-current","true"):r.button.removeAttribute("aria-current"),z(r.name,n.name),z(r.branch,n.branch),r.branch.hidden=!n.branch,z(r.path,n.path),r.root!==i&&this.#i.insertBefore(r.root,i),i=r.root.nextSibling}this.#r.hidden=t.length>0}#u(t){const e=l("strong",{className:"terminal-workspace-name"}),i=l("span",{className:"terminal-workspace-branch"}),n=l("span",{className:"terminal-workspace-path"}),r=l("button",{className:"terminal-workspace-label",attrs:{type:"button","data-view-key":`sidebar-workspace:${t}`},on:{click:()=>{a.option?.target&&this.onSelectPane(a.option.target)}}},l("span",{className:"terminal-workspace-copy"},l("span",{className:"terminal-workspace-line"},l("span",{className:"terminal-workspace-dot",attrs:{"aria-hidden":"true"}}),e,i),n)),a={option:void 0,root:l("section",{className:"terminal-workspace-group",attrs:{"data-sidebar-workspace":String(t)}},r),button:r,name:e,branch:i,path:n};return this.#s.set(t,a),a}}function z(s,t){s.textContent!==t&&(s.textContent=t)}const $e=32*1024*1024,et=160*1024,it=new TextEncoder;async function Ie(s,t){if(!s.size||s.size>$e)throw new Error("Files must be between 1 byte and 32 MiB.");const e=await t("upload_start",{name:Fe(s.name),size:s.size});if(e.type!=="terminal_upload"||!Oe(e.upload_id))throw new Error("The server did not create a terminal upload.");const i=e.upload_id,n=Number.isSafeInteger(e.max_chunk_bytes)&&Number(e.max_chunk_bytes)>0?Math.min(et,Number(e.max_chunk_bytes)):et;try{for(let r=0;r{}),r}}function Fe(s){const t=s.split(/[\\/]/).at(-1)?.trim()||"attachment";if(it.encode(t).byteLength<=255)return t;let e="";for(const i of t){if(it.encode(e+i).byteLength>240)break;e+=i}return e||"attachment"}function Oe(s){return typeof s=="string"&&/^[0-9a-f]{32}$/.test(s)}function Be(s){let t="";for(let e=0;ethis.#s(n)),this.element.addEventListener("beforeinput",n=>this.#u(n)),this.element.addEventListener("input",()=>this.#f()),this.element.addEventListener("compositionstart",()=>{this.#t=!0}),this.element.addEventListener("compositionend",n=>this.#l(n)),this.element.addEventListener("paste",n=>this.#h(n))}dispatch;onError;onFiles;element=l("textarea",{className:"terminal-input-proxy",attrs:{"aria-label":"Terminal keyboard input",autocomplete:"off",autocapitalize:"off",autocorrect:"off",enterkeyhint:"enter",inputmode:"text",rows:"1",spellcheck:"false"}});#e="";#t=!1;#i=!1;#r;#n="";focus(){this.#i||this.element.focus({preventScroll:!0})}blur(){this.element.blur()}sendKey(t){this.#m(),this.#a("send_key",{key:t}),this.focus()}destroy(){this.#i=!0,this.#e="",this.#r!==void 0&&cancelAnimationFrame(this.#r),this.#r=void 0,this.element.remove()}#s(t){if(t.isComposing||this.#t)return;const e=ze(t);e&&(e==="ctrl-c"&&window.getSelection()?.toString()||(t.preventDefault(),this.sendKey(e)))}#u(t){if(t.isComposing||this.#t)return;if(t.inputType==="insertLineBreak"||t.inputType==="insertParagraph"){t.preventDefault(),this.sendKey("enter");return}const e=qe(t.inputType);if(e){t.preventDefault(),this.sendKey(e);return}t.data&&t.inputType.startsWith("insert")&&(t.preventDefault(),this.element.value="",this.#c(t.data))}#f(){if(this.#t||!this.element.value)return;const t=this.element.value;if(this.element.value="",t===this.#n){this.#n="";return}this.#n="",this.#c(t)}#l(t){this.#t=!1;const e=t.data||this.element.value;this.element.value="",e&&(this.#n=e,queueMicrotask(()=>{this.#n=""}),this.#c(e))}#h(t){const e=t.clipboardData,i=Array.from(e?.files??[]);if(!i.length)for(const r of Array.from(e?.items??[])){const o=r.kind==="file"?r.getAsFile():null;o&&i.push(o)}if(i.length){t.preventDefault(),this.element.value="",this.#m(),this.onFiles(i);return}const n=t.clipboardData?.getData("text/plain")??"";if(n){if(t.preventDefault(),this.element.value="",this.#m(),nt.encode(n).byteLength>st){this.onError("Paste is larger than the 256 KiB terminal input limit.");return}this.#a("paste_text",{text:n})}}#c(t){this.#e+=t,this.#r===void 0&&(this.#r=requestAnimationFrame(()=>{this.#r=void 0,this.#m()}))}#m(){if(this.#r!==void 0&&cancelAnimationFrame(this.#r),this.#r=void 0,!this.#e)return;const t=this.#e;if(this.#e="",nt.encode(t).byteLength>st){this.onError("Input is larger than the 256 KiB terminal input limit.");return}this.#a("type_literal",{text:t})}#a(t,e){this.#i||this.dispatch(t,e).catch(i=>{if(this.#i)return;const n=i instanceof Error?i.message:"Terminal input failed";this.onError(n)})}}const We=new Set(["bridge_error","closed","disconnected","stale_server","stale_stream","timeout","unavailable"]);function rt(s,t=4){return s instanceof f?s.code==="control_conflict"?t<4:We.has(s.code):!0}const Ue=["#181922","#ed8f9e","#9ed68a","#ead778","#90b9f2","#c6a0f6","#7bdff2","#d9d7e7","#676579","#f3a7b4","#b5e8a4","#f1e38f","#a9c9f7","#d5b7fa","#9ae8f5","#f4f1ff"];function Ke(s){const t=[],e=Tt(),i=/\x1b\[([0-9;]*)m/g;let n=0;for(const r of s.matchAll(i)){const o=r.index??n;ot(t,at(s.slice(n,o)),e),je(e,r[1]?r[1].split(";").map(Number):[0]),n=o+r[0].length}return ot(t,at(s.slice(n)),e),t}function Tt(){return{foreground:void 0,background:void 0,bold:!1,dim:!1,italic:!1,underline:!1,reverse:!1,hidden:!1,strike:!1}}function He(s){Object.assign(s,Tt(),{foreground:void 0,background:void 0})}function at(s){return s.replace(/\r\n?/g,` -`).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g,"")}function ot(s,t,e){if(!t)return;const i=Ve(e);s.push({text:t,...i?{style:i}:{}})}function Ve(s){let t=s.foreground,e=s.background;s.reverse&&([t,e]=[e??"#d9d7e7",t??"#111219"]);const i={};return t&&(i.color=t),e&&(i.backgroundColor=e),s.bold&&(i.fontWeight="700"),s.dim&&(i.opacity="0.65"),s.italic&&(i.fontStyle="italic"),(s.underline||s.strike)&&(i.textDecoration=[s.underline?"underline":"",s.strike?"line-through":""].filter(Boolean).join(" ")),s.hidden&&(i.visibility="hidden"),Object.keys(i).length?i:void 0}function je(s,t){for(let e=0;e=30&&i<=37)s.foreground=C(i-30);else if(i>=90&&i<=97)s.foreground=C(i-90+8);else if(i===39)s.foreground=void 0;else if(i>=40&&i<=47)s.background=C(i-40);else if(i>=100&&i<=107)s.background=C(i-100+8);else if(i===49)s.background=void 0;else if(i===38||i===48){const n=Ge(t,e+1);n&&(i===38?s.foreground=n.value:s.background=n.value,e+=n.consumed)}}}function Ge(s,t){if(s[t]===5&&Number.isInteger(s[t+1]))return{value:C(s[t+1]),consumed:2};if(s[t]===2&&[s[t+1],s[t+2],s[t+3]].every(Number.isInteger)){const[e,i,n]=s.slice(t+1,t+4).map(Ye);return{value:`rgb(${e}, ${i}, ${n})`,consumed:4}}}function C(s){const t=Math.max(0,Math.min(255,s));if(t<16)return Ue[t];if(t<232){const i=t-16,n=r=>r===0?0:55+r*40;return`rgb(${n(Math.floor(i/36))}, ${n(Math.floor(i%36/6))}, ${n(i%6)})`}const e=8+(t-232)*10;return`rgb(${e}, ${e}, ${e})`}function Ye(s){return Math.max(0,Math.min(255,s))}function Xe(s,t,e=0){const i=[];let n=t,r=!1;const o=(a,c)=>{a&&i.push({kind:"text",text:a,...c?{style:c}:{}})};for(const a of Ke(s)){const c=Array.from(a.text);!r&&n!==void 0&&n<=c.length?(o(c.slice(0,n).join(""),a.style),o(" ".repeat(e)),i.push({kind:"cursor"}),o(c.slice(n).join(""),a.style),r=!0):(o(a.text,a.style),!r&&n!==void 0&&(n-=c.length))}return!r&&n===0&&(o(" ".repeat(e)),i.push({kind:"cursor"})),i}function Ze(s,t){const e=s.ownerDocument,i=e.getSelection();let n;if(i&&!i.isCollapsed&&i.anchorNode&&i.focusNode&&s.contains(i.anchorNode)&&s.contains(i.focusNode)){const o=e.createRange();o.selectNodeContents(s),o.setEnd(i.anchorNode,i.anchorOffset);const a=o.toString().length;o.setEnd(i.focusNode,i.focusOffset),n={text:s.textContent??"",anchor:a,focus:o.toString().length}}let r=s.firstChild;for(const o of t){const a=o.kind==="cursor"?"terminal-caret":o.style?"terminal-run":void 0;let c=r;if((a?c instanceof HTMLElement&&c.className===a:c instanceof Text)||(c=a?l("span",{className:a}):e.createTextNode(""),o.kind==="cursor"&&c.setAttribute("aria-hidden","true"),r?s.replaceChild(c,r):s.append(c)),o.kind==="text"){let u;if(c instanceof Text)u=c;else{const d=c,p=JSON.stringify(o.style);d.dataset.terminalStyle!==p&&(d.style.cssText="",Object.assign(d.style,o.style),d.dataset.terminalStyle=p),d.firstChild instanceof Text||d.append(e.createTextNode("")),u=d.firstChild}Qe(u,o.text)}r=c.nextSibling}for(;r;){const o=r.nextSibling;s.removeChild(r),r=o}if(n&&i){const o=n.anchor>n.focus,a=Je(n.text,s.textContent??"",Math.min(n.anchor,n.focus),Math.max(n.anchor,n.focus)),c=a&<(s,a[0]),h=a&<(s,a[1]);if(c&&h){const u=o?h:c,d=o?c:h;(i.anchorNode!==u.node||i.anchorOffset!==u.offset||i.focusNode!==d.node||i.focusOffset!==d.offset)&&i.setBaseAndExtent(u.node,u.offset,d.node,d.offset)}else i.removeAllRanges()}}function Je(s,t,e,i){if(e<0||i>s.length||e>=i)return;let n=0;for(;n=s.length-r)return[e+t.length-s.length,i+t.length-s.length];const o=s.slice(e,i),a=t.indexOf(o);if(a<0)return;if(t.indexOf(o,a+1)<0&&s.indexOf(o)===e&&s.indexOf(o,e+1)<0)return[a,a+o.length];const c=Math.max(0,e-32),h=s.slice(c,i+32),u=t.indexOf(h);if(u<0||t.indexOf(h,u+1)>=0||s.indexOf(h)!==c||s.indexOf(h,c+1)>=0)return;const d=u+e-c;return[d,d+o.length]}function lt(s,t){const e=s.ownerDocument.createTreeWalker(s,NodeFilter.SHOW_TEXT);for(;e.nextNode();){const i=e.currentNode;if(t<=i.length)return{node:i,offset:t};t-=i.length}}function Qe(s,t){const e=s.data;if(e===t)return;let i=0;for(;i=0)return;let r=n+e.contextOffset;const o=t.ownerDocument,a=o.createTreeWalker(t,NodeFilter.SHOW_TEXT);for(;a.nextNode();){const c=a.currentNode;if(r>=c.length){r-=c.length;continue}const h=o.createRange();h.setStart(c,r),h.setEnd(c,r+1);const u=h.getClientRects()[0];u&&(s.scrollTop+=u.top-e.top);return}}class ii{session;serverGeneration;#e;#t;constructor(t,e){if(!e.terminal_id||!ct(t,e.pane_id,e.terminal_id))throw new Error("Pane has no live terminal route");this.session=t.session,this.serverGeneration=t.server_generation,this.#e=e.pane_id,this.#t=e.terminal_id}resolve(t){if(t.session!==this.session||t.server_generation!==this.serverGeneration)return;const e=ct(t,this.#e,this.#t);if(e)return this.#e=e.pane_id,this.#t=e.terminal_id,{serverGeneration:t.server_generation,pane:e}}}function ct(s,t,e){if(e)for(const i of s.workspaces)for(const n of i.tabs){const r=n.panes.find(o=>o.kind==="terminal"&&o.pane_id===t&&o.terminal_id===e);if(r)return r}}class si{constructor(t,e,i,n,r,o,a,c,h,u,d,p){this.bridge=t,this.control=n,this.canUploadFiles=r,this.streamCursor=o,this.viewportSizing=a,this.paneOptions=c,this.onSelectPane=h,this.sidebarControls=p,this.#r=e,this.#i=new ii(e,i),this.#t=this.#i.resolve(e),n?(this.#d=new De((m,y)=>this.#Y(m,y),m=>this.#et(m),m=>{this.canUploadFiles&&this.#X(m)}),this.#d.element.addEventListener("focus",()=>this.#H(!0)),this.#d.element.addEventListener("focus",()=>{this.#g.textContent="Typing in terminal",this.root.classList.add("keyboard-active"),this.#k=!0,this.#W(!0)}),this.#d.element.addEventListener("blur",()=>{this.#g.textContent="Tap terminal to type",this.root.classList.remove("keyboard-active"),this.#W()}),this.#a.addEventListener("click",()=>{window.getSelection()?.toString()||this.#d?.focus()})):this.#g.textContent="Read-only terminal";const g=l("input",{className:"terminal-file-input",attrs:{type:"file",multiple:"","aria-label":"Attach files"}});g.disabled=!r;const v=b("+","terminal-tool attach-file",()=>{this.canUploadFiles&&g.click()});this.#v=v,v.disabled=!r,v.setAttribute("aria-label","Attach files"),v.title="Attach files",g.addEventListener("change",()=>{const m=Array.from(g.files??[]);g.value="",m.length&&this.#X(m)}),r&&(this.root.addEventListener("dragenter",m=>this.#it(m)),this.root.addEventListener("dragover",m=>this.#it(m)),this.root.addEventListener("dragleave",m=>{(!(m.relatedTarget instanceof Node)||!this.root.contains(m.relatedTarget))&&this.root.classList.remove("file-drag-active")}),this.root.addEventListener("drop",m=>{const y=Array.from(m.dataTransfer?.files??[]);y.length&&(m.preventDefault(),this.root.classList.remove("file-drag-active"),this.#X(y))}));const _=b("⌨","terminal-tool keyboard-toggle",()=>{document.activeElement===this.#d?.element?this.#d.blur():this.#d?.focus()});_.disabled=!n,_.setAttribute("aria-label","Show or hide keyboard"),_.title="Keyboard";const T=[["escape","Esc"],["tab","Tab"],["up","↑"],["down","↓"],["left","←"],["right","→"],["ctrl-c","⌃C"]].map(([m,y])=>{const M=b(y,"terminal-tool",()=>this.#d?.sendKey(m));return M.disabled=!n,M.setAttribute("aria-label",m==="ctrl-c"?"Control C":m),M.addEventListener("pointerdown",Lt=>Lt.preventDefault()),M}),L=l("div",{className:"terminal-tools"},v,_,...T),N=b("","terminal-controls-toggle",()=>{const m=this.root.classList.toggle("controls-expanded");N.setAttribute("aria-expanded",String(m)),N.setAttribute("aria-label",m?"Hide terminal controls":"Show terminal controls"),N.title=m?"Hide terminal controls":"Show terminal controls"});N.setAttribute("aria-expanded","false"),N.setAttribute("aria-label","Show terminal controls"),N.title="Show terminal controls",N.addEventListener("pointerdown",m=>m.preventDefault()),this.#D=l("button",{className:"terminal-pane-selector",attrs:{type:"button","aria-label":"Open navigation",title:"Open navigation","aria-haspopup":"dialog","aria-expanded":"false","aria-controls":"terminal-navigation"},on:{click:()=>this.#st()}},l("span",{className:"terminal-pane-dot",attrs:{"aria-hidden":"true"}}));const Pt=l("div",{className:"terminal-pane-switcher"},this.#D),Y=m=>{const y=this.#A;this.#I(y),m.pane_id===this.#t?.pane.pane_id&&m.terminal_id===this.#t?.pane.terminal_id?y||this.#d?.focus():this.onSelectPane(m)};this.#x=new xt(Y,d,()=>this.#I(!0),()=>p.toggle("panes")),this.#x.closeButton.setAttribute("aria-label","Close navigation"),this.#x.closeButton.setAttribute("aria-controls","terminal-navigation"),this.#x.closeButton.title="Close navigation",this.#R=new Et(Y,u,()=>p.toggle("workspaces")),this.#R.setFooter(p.footer()),this.#o.append(l("header",{className:"terminal-navigation-header"},O(u),this.#x.closeButton),this.#R.root,this.#x.root),this.updateSidebarLayout(),this.#z(),this.#q(),this.#P=l("header",{className:"terminal-header"},O(u),Pt),this.#$=l("div",{className:"terminal-main"},this.#a,l("div",{className:"terminal-controls-wrap"},l("div",{className:"terminal-controls"},this.#g,L,N))),this.#E.append(this.#P,this.#$),this.root.append(this.#o,this.#E,g,...this.#d?[this.#d.element]:[]),this.#a.append(this.#w),this.#tt(),this.viewportSizing&&(this.#B=new ResizeObserver(()=>this.#H()),this.#B.observe(this.#a)),this.#a.addEventListener("scroll",()=>{const m=this.#a.scrollHeight-this.#a.scrollTop-this.#a.clientHeight;this.#k=m<80},{passive:!0}),window.visualViewport?.addEventListener("resize",this.#S),window.visualViewport?.addEventListener("scroll",this.#S),window.addEventListener("resize",this.#S),document.addEventListener("keydown",this.#Q),this.#L.addEventListener("change",this.#j),this.#W()}bridge;control;canUploadFiles;streamCursor;viewportSizing;paneOptions;onSelectPane;sidebarControls;root=l("section",{className:"terminal-screen"});#e;#t;#i;#r;#n=0;#s;#u=0;#f;#l=!1;#h=[];#c;#m=l("div",{className:"terminal-content"});#a=l("div",{className:"terminal-output",attrs:{role:"log",tabindex:"0","aria-label":"Terminal output"}},this.#m);#p;#_;#b;#d;#g=l("span",{className:"terminal-input-hint",text:"Tap terminal to type"});#v;#F=Promise.resolve();#k=!0;#T;#S=()=>{this.#W(),this.#H()};#w=l("span",{className:"terminal-cell-probe",attrs:{"aria-hidden":"true"},text:"0000000000"});#y;#M;#B;#$;#P;#E=l("div",{className:"web-content-frame"});#o=l("div",{className:"terminal-navigation",attrs:{id:"terminal-navigation","aria-label":"Workspaces and panes"}});#A=!1;#D;#x;#R;#L=matchMedia("(min-width: 1024px)");#j=()=>{this.#L.matches&&this.#I(),this.#tt(),this.#z(),this.#q()};#Q=t=>{this.#A&&(t.key==="Escape"?(t.preventDefault(),this.#I(!0)):kt(t,this.#o))};async start(){if(!this.#t)throw new Error("Pane has no live terminal identity");await this.#K(!0),this.control&&!this.#A&&matchMedia("(pointer: fine)").matches&&this.#d?.focus()}async#K(t=!1){if(this.#l)return;const e=this.#t;if(!e?.pane.terminal_id){if(t)throw new Error("Pane has no live terminal identity");return}this.#s&&clearTimeout(this.#s),this.#s=void 0;const i=++this.#n,n=this.control?"terminal.backend.control":"terminal.backend.observe";try{await this.bridge.connect();const r=await this.bridge.openStream(n,{server_generation:e.serverGeneration,terminal_id:e.pane.terminal_id,pane_id:e.pane.pane_id,mode:"recent_unwrapped",lines:120,ansi:!0,...this.streamCursor?{cursor:!0}:{}},o=>this.#nt(o,i),o=>this.#ot(i,o));if(this.#l||i!==this.#n){r.close();return}this.#e=r,this.#u=0,this.#rt(),this.#V(),this.#J(r),this.#y=void 0,this.#H(!0)}catch(r){if(this.#l||i!==this.#n)return;if(this.#e=void 0,!rt(r,this.#u)){if(this.#N(r),t)throw r;this.#C(r instanceof Error?r.message:"Terminal connection failed");return}this.#C(r instanceof f&&r.code==="control_conflict"?"Waiting for terminal control…":"Connection interrupted — reconnecting…"),this.#O()}}destroy(){this.#l=!0,this.#n+=1,this.#s&&clearTimeout(this.#s),this.#s=void 0,this.#N(new f("Terminal view closed","closed")),this.#d?.destroy(),this.#e?.close(),this.#e=void 0,this.#p!==void 0&&cancelAnimationFrame(this.#p),this.#T!==void 0&&cancelAnimationFrame(this.#T),this.#M!==void 0&&clearTimeout(this.#M),this.#B?.disconnect(),window.visualViewport?.removeEventListener("resize",this.#S),window.visualViewport?.removeEventListener("scroll",this.#S),window.removeEventListener("resize",this.#S),document.removeEventListener("keydown",this.#Q),this.#L.removeEventListener("change",this.#j)}updateSnapshot(t){if(this.#l)return;if(t.session!==this.#i.session){this.#I(),this.#x.update([],void 0),this.#R.update([]),this.#r=t,this.#t=void 0,this.#s&&clearTimeout(this.#s),this.#s=void 0;const o=this.#e;this.#n+=1,this.#e=void 0,o?.close(),this.#f=new f("Terminal belongs to another session","stale_stream"),this.#N(this.#f),this.#C("Session changed — return to Mission Control to choose a terminal");return}const e=this.#t;this.#r=t;const i=t.server_generation!==this.#i.serverGeneration,n=this.#i.resolve(t);if(this.#t=n,this.#z(),this.#q(),!n){this.#s&&clearTimeout(this.#s),this.#s=void 0;const o=this.#e;this.#n+=1,this.#e=void 0,o?.close(),i?(this.#f=new f("The server restarted; choose the terminal again before sending input","stale_server"),this.#N(this.#f),this.#C("Server restarted — return to Mission Control and choose the terminal again")):this.#C("Waiting for terminal to restore…");return}this.#f=void 0,(!e||e.serverGeneration!==n.serverGeneration||e.pane.pane_id!==n.pane.pane_id||e.pane.terminal_id!==n.pane.terminal_id)&&(this.#u=0,this.#e?this.#Z(this.#n,"Terminal identity changed"):(this.#n+=1,this.#s&&clearTimeout(this.#s),this.#s=void 0,this.#O(!0)))}updateTitles(t){this.#l||!this.#L.matches&&!this.#A||this.#z()}updateSidebarLayout(){const{workspaces:t,panes:e}=this.sidebarControls.collapsed;this.root.classList.toggle("workspaces-collapsed",t),this.root.classList.toggle("panes-collapsed",e),this.#R.setCollapsed(t),this.#x.setCollapsed(e)}closeNavigation(){this.#I()}#tt(){const t=this.#L.matches?this.root:this.#o;this.#x.root.parentElement!==t&&t.append(this.#x.root)}#z(t){this.#x.update(this.#L.matches||this.#A?t??this.paneOptions():[],this.#t?.pane)}#q(){this.#R.update((this.#L.matches||this.#A)&&this.#r.session===this.#i.session?Nt(this.#r,this.#t?.pane):[])}#st(){this.#l||this.#L.matches||(this.#A=!0,this.#d?.blur(),this.#P.inert=!0,this.#$.inert=!0,this.#d&&(this.#d.element.inert=!0),this.root.classList.add("pane-navigation-open"),this.#o.setAttribute("role","dialog"),this.#o.setAttribute("aria-modal","true"),this.#D?.setAttribute("aria-expanded","true"),this.#z(),this.#q(),this.#x.closeButton.focus({preventScroll:!0}))}#I(t=!1){this.#A&&(this.#A=!1,this.root.classList.remove("pane-navigation-open"),this.#o.removeAttribute("role"),this.#o.removeAttribute("aria-modal"),this.#P.inert=!1,this.#$.inert=!1,this.#d&&(this.#d.element.inert=!1),this.#D?.setAttribute("aria-expanded","false"),this.#z(),this.#q(),t&&!this.#L.matches&&this.#D?.focus({preventScroll:!0}))}#nt(t,e){if(this.#l||e!==this.#n)return;if(t.event==="terminal.resync_required"){this.#Z(e,"Terminal changed while the connection was catching up");return}if(t.event!=="terminal.frame")return;const i=t;if(typeof i.data.text=="string"){const n=i.data.cursor?.offset,r=i.data.cursor?.padding_cells;this.#G(i.data.text,typeof n=="number"&&Number.isSafeInteger(n)&&n>=0?n:void 0,typeof r=="number"&&Number.isSafeInteger(r)&&r>=0?r:0)}}#G(t,e,i=0){this.#_={text:t,cursorOffset:e,cursorPadding:i},this.#p===void 0&&(this.#p=requestAnimationFrame(()=>{this.#p=void 0;const n=this.#_;if(this.#_=void 0,n===void 0||n.text===this.#b?.text&&n.cursorOffset===this.#b.cursorOffset&&n.cursorPadding===this.#b.cursorPadding)return;const r=this.#k,o=r?void 0:ti(this.#a,this.#m);Ze(this.#m,Xe(n.text,n.cursorOffset,n.cursorPadding)),this.#b=n,r?this.#U():o&&ei(this.#a,this.#m,o)}))}#C(t){this.#c?.isConnected||(this.#c=l("div",{className:"terminal-status terminal-connection-status"}),this.#a.append(this.#c)),this.#c.textContent=t,this.#a.scrollTop=this.#a.scrollHeight}#rt(){this.#c?.remove(),this.#c=void 0}#et(t){if(!this.#e){this.#C("Connection interrupted — reconnecting…"),this.#O();return}this.#C(`Input was not delivered: ${t}`)}async#Y(t,e){if(this.#k=!0,this.#U(),this.#f)throw this.#f;const i=this.#e;if(!i){if(this.#l)throw new f("Terminal view is closed","closed");if(this.#h.length>=256)throw new f("Terminal reconnect input buffer is full","input_buffer_full");return this.#O(),new Promise((n,r)=>{this.#h.push({action:t,params:e,resolve:n,reject:r})})}try{return await i.action(t,e)}catch(n){throw this.#e===i&&rt(n)&&this.#Z(this.#n,"Terminal input connection was interrupted"),n}}#X(t){this.canUploadFiles&&(this.#F=this.#F.then(async()=>{if(t.length){this.#v&&(this.#v.disabled=!0),this.#g.textContent=`Uploading ${t.length===1?t[0].name:`${t.length} files`}`;try{for(let e=0;ethis.#Y(i,n)),e+1{this.#M=void 0,this.#at(t)},150))}async#at(t){const e=this.#e;if(!e||this.#l)return;const i=getComputedStyle(this.#a),n=this.#w.getBoundingClientRect(),r=Ne(this.#a.clientWidth-parseFloat(i.paddingLeft)-parseFloat(i.paddingRight),this.#a.clientHeight-parseFloat(i.paddingTop)-parseFloat(i.paddingBottom),n.width/10,n.height);if(!(!r||!t&&xe(r,this.#y))){this.#y=r;try{await e.action("set_viewport",{cols:r.cols,rows:r.rows})}catch{this.#y=void 0}}}#U(){this.#a.scrollTop=this.#a.scrollHeight,this.#T!==void 0&&cancelAnimationFrame(this.#T),this.#T=requestAnimationFrame(()=>{this.#T=void 0,this.#k&&(this.#a.scrollTop=this.#a.scrollHeight)})}#ot(t,e){this.#l||t!==this.#n||(this.#n+=1,this.#e=void 0,this.#C(e?"Connection interrupted — reconnecting…":"Reconnecting terminal…"),this.#O())}#Z(t,e){if(this.#l||t!==this.#n)return;const i=this.#e;this.#n+=1,this.#e=void 0,i?.close(),this.#C("Connection interrupted — reconnecting…"),this.#O()}#O(t=!1){if(this.#l||this.#e||this.#s)return;const e=t?0:Math.min(5e3,150*2**Math.min(this.#u++,5));this.#s=setTimeout(()=>{this.#s=void 0,this.#K()},e)}#J(t){const e=this.#h;this.#h=[];for(const i of e)t.action(i.action,i.params).then(i.resolve,i.reject)}#N(t){const e=this.#h;this.#h=[];for(const i of e)i.reject(t)}#V(){this.control?document.activeElement===this.#d?.element?this.#g.textContent="Typing in terminal":this.#g.textContent="Tap terminal to type":this.#g.textContent="Read-only terminal"}}function ht(s,t){const e=s.ownerDocument.activeElement,i=e&&e!==s&&s.contains(e)?{tag:e.tagName,...q(s,e)}:void 0,n=i&&K(e)?ri(e):void 0,r=new Map;for(const a of dt(s))if(!a.readOnly&&a.value!==a.defaultValue){const c=q(s,a);r.set(`${c.key}#${c.index}`,a.value)}const o=new Map;for(const a of Array.from(s.querySelectorAll("[data-scroll-key]")))(a.scrollTop||a.scrollLeft)&&o.set(a.dataset.scrollKey??"",[a.scrollTop,a.scrollLeft]);if(t(),r.size)for(const a of dt(s)){const c=q(s,a),h=r.get(`${c.key}#${c.index}`);h!==void 0&&!a.readOnly&&(a.value=h)}for(const a of Array.from(s.querySelectorAll("[data-scroll-key]"))){const c=o.get(a.dataset.scrollKey??"");c&&([a.scrollTop,a.scrollLeft]=c)}if(i){const a=Array.from(s.querySelectorAll(i.tag)).filter(c=>U(c)===i.key)[i.index];a&&!a.hasAttribute("disabled")&&(a.focus({preventScroll:!0}),n&&K(a)&&ai(a,n))}}function q(s,t){const e=U(t),i=Array.from(s.querySelectorAll(t.tagName)).filter(n=>U(n)===e);return{key:e,index:Math.max(0,i.indexOf(t))}}function U(s){const t=s.dataset.viewKey;if(t)return`key:${t}`;const e=s.getAttribute("aria-label")??s.getAttribute("name")??"",i=s.tagName==="BUTTON"?(s.textContent??"").trim().slice(0,80):"";return`${s.tagName}.${s.className}|${e}|${i}`}const ni=new Set(["text","url","search","tel","password",""]);function dt(s){return Array.from(s.querySelectorAll("input, textarea")).filter(K)}function K(s){return s?s.tagName==="TEXTAREA"?!0:s.tagName==="INPUT"&&ni.has(s.type):!1}function ri(s){try{return[s.selectionStart,s.selectionEnd,s.selectionDirection]}catch{return}}function ai(s,[t,e,i]){if(!(t===null||e===null))try{s.setSelectionRange(t,e,i??void 0)}catch{}}function oi(s,t){const e=s.value;s.defaultValue=t,s.value=e}class li{constructor(t){this.root=t;const e=r=>{const o=this.#t.snapshot;o&&this.#O(o,r)},i=()=>{this.#U(!0),this.root.querySelector(".dashboard")?.scrollTo({top:0,behavior:"smooth"}),window.scrollTo({top:0,behavior:"smooth"})};this.#_=new Et(e,i,()=>this.#w.toggle("workspaces")),this.#b=new xt(e,{showShells:this.#m,onChange:r=>{this.#m=r,this.#o()}},()=>this.#U(!0),()=>this.#w.toggle("panes")),this.#k=this.#b.closeButton,this.#k.setAttribute("aria-label","Close navigation"),this.#k.setAttribute("aria-controls","dashboard-navigation"),this.#k.title="Close navigation",this.#F.setAttribute("aria-haspopup","dialog"),this.#T=l("header",{className:"dashboard-header"},O(i),this.#F),this.#g.append(l("header",{className:"terminal-navigation-header"},O(i),this.#k),this.#_.root,this.#b.root),this.#S.addEventListener("change",()=>{this.#S.matches&&(this.#v=!1),this.#o()}),document.addEventListener("keydown",r=>{const o=this.#n?this.#d.querySelector(".device-panel"):!this.#i&&this.#v?this.#g:void 0;o&&(r.key==="Escape"?(r.preventDefault(),this.#n?this.#G():this.#U(!0)):kt(r,o))}),t.addEventListener("pointerdown",r=>this.#E.hold(r.pointerId),!0);for(const r of["pointerup","pointercancel"])window.addEventListener(r,o=>this.#E.release(o.pointerId),!0);window.addEventListener("pointermove",r=>{r.buttons===0&&this.#E.release(r.pointerId)},!0),window.addEventListener("blur",()=>this.#E.releaseAll()),document.addEventListener("visibilitychange",()=>{document.hidden&&this.#E.releaseAll()}),this.#y=ci();const n=location.protocol==="https:"?"wss:":"ws:";this.#e=new $t(`${n}//${location.host}/bridge`,()=>{this.#P=this.#$.get();const r=Wt(this.#P,this.#y);return this.#M={ticket:!!r.ticket,code:!!r.code},r},r=>{this.#$.set(r),this.#P=r,this.#y=void 0}),this.#e.addEventListener("devices",r=>{try{this.#r=P(r.detail),this.#u&&(this.#f=this.#I(this.#u)),this.#E.request()}catch(o){this.#N(o)}}),this.#t=new Ot(this.#e),this.#t.addEventListener("state",()=>{this.#t.state==="ready"&&(this.#y=void 0),this.#t.state==="expired"&&this.#i&&(this.#i.destroy(),this.#i=void 0),this.#E.request(),this.#t.state==="ready"&&!this.#r&&this.#K()}),this.#t.addEventListener("snapshot",()=>{const r=this.#t.snapshot;r&&this.#i?.updateSnapshot(r),this.#E.request()}),this.#t.addEventListener("titles",r=>this.#D(r.detail)),window.addEventListener("storage",r=>{(r.key===x||r.key===null)&&this.#$.get()!==this.#P&&location.reload()})}root;#e;#t;#i;#r;#n=!1;#s=!1;#u;#f;#l;#h=!1;#c=!1;#m=!1;#a=new Me;#p=new Le;#_;#b;#d=l("div",{className:"web-overlays"});#g=l("div",{className:"terminal-navigation",attrs:{id:"dashboard-navigation","aria-label":"Workspaces and panes"}});#v=!1;#F=me("Open navigation","dashboard-navigation",()=>this.#at());#k;#T;#S=matchMedia("(min-width: 1024px)");#w={collapsed:{workspaces:!1,panes:!1},toggle:t=>{this.#w.collapsed[t]=!this.#w.collapsed[t],this.#i?this.#i.updateSidebarLayout():this.#o()},refresh:()=>{this.#t.refresh().catch(t=>this.#N(t))},footer:()=>this.#W()};#y;#M={ticket:!1,code:!1};#B;#$=new Dt;#P=null;#E=new pe(()=>this.#A());async start(){this.#o();try{await this.#t.start()}catch(t){this.#t.state!=="expired"&&this.#N(t)}}#o(){this.#E.holding?this.#E.request():this.#A()}#A(){if(this.#i){this.#i.root.inert=this.#n,this.#H(),ht(this.#d,()=>this.#d.replaceChildren(...this.#n?[this.#j()]:[]));return}const t=this.#t.snapshot,e=this.#t.state==="expired";e&&(this.#v=!1,this.#n=!1),ht(this.root,()=>{this.#d.replaceChildren(...!e&&t&&this.#n?[this.#j()]:[]),this.root.replaceChildren(l("div",{className:"shell"},e?l("div",{className:"dashboard loading-dashboard"},this.#R()):t?this.#ot(t):l("div",{className:"dashboard loading-dashboard"},this.#x()),this.#d))});const i=this.root.querySelector(".dashboard-layout");i&&(i.inert=this.#n)}#D(t){if(this.#t.snapshot){if(this.#i){this.#i.updateTitles(t);return}(this.#S.matches||this.#v)&&this.#t.state!=="expired"&&this.#b.update(this.#J(),void 0,this.#m)}}#x(){return l("section",{className:"empty-state"},l("div",{className:"pulse"}),l("h1",{text:"Connecting to Luvus"}),l("p",{text:"Authenticating and reconciling the live session."}))}#R(){const t=Ht(this.#M),e=l("input",{className:"pair-input",attrs:{type:"text",placeholder:"Paste a pairing link","aria-label":"Pairing link or code",autocomplete:"off",autocapitalize:"off",spellcheck:"false"}}),i=l("form",{className:"pair-form",on:{submit:n=>{n.preventDefault(),this.#L(e.value)}}},e,l("button",{className:"primary",text:"Connect",attrs:{type:"submit"}}));return l("section",{className:"empty-state access-problem",attrs:{role:"alert"}},l("h1",{text:t.title}),l("p",{text:t.body}),i,this.#B?l("p",{className:"pair-error",text:this.#B}):void 0)}#L(t){const e=Kt(t);if(!e){this.#B="That is not a Luvus pairing link or code.",this.#o();return}this.#M.ticket&&this.#$.clear(this.#P),this.#y=e,this.#B=void 0,this.start()}#j(){const t=this.#r,e=t?t.paired_devices+t.pending_pairings:1,i=l("select",{className:"device-limit",attrs:{"aria-label":"Maximum paired devices",...this.#s?{disabled:""}:{}},on:{change:d=>{this.#z(Number(d.currentTarget.value))}}}),n=t?.limit_ceiling??8;for(let d=1;d<=n;d+=1)i.append(l("option",{text:String(d),attrs:{value:String(d),...t?.max_devices===d?{selected:""}:{},...d=(t?.max_devices??1)?"Device limit reached":"Pair another device","primary device-pair",()=>{this.#st()});r.disabled=this.#s||!t||e>=t.max_devices;const o=l("input",{className:"device-url-input",attrs:{type:"url",inputmode:"url",autocomplete:"url",placeholder:location.origin,value:t?.public_url??"","aria-label":"Public pairing address",...this.#s?{disabled:""}:{}},on:{keydown:d=>{d.key==="Enter"&&this.#q(d.currentTarget.value)}}}),a=b("Save address","ghost device-url-save",()=>{this.#q(o.value)});a.disabled=this.#s||!t;const c=b("Disconnect this browser","ghost device-disconnect",()=>{this.#tt()});c.disabled=this.#s||!t;const h=l("section",{className:"device-panel",attrs:{role:"dialog","aria-modal":"true","aria-labelledby":"device-title"}},l("div",{className:"device-panel-head"},l("div",{},l("p",{className:"eyebrow",text:"BROWSER ACCESS"}),l("h2",{text:"Connected devices",attrs:{id:"device-title"}})),b("Close","device-close",()=>this.#G())),l("p",{className:"device-copy",text:t?`${t.paired_devices} authorized${t.pending_pairings?` · ${t.pending_pairings} link pending`:""}`:"Loading device access…"}),l("label",{className:"device-limit-row"},l("span",{text:"Maximum devices"}),i),l("div",{className:"device-url-setting"},l("label",{className:"device-url-label"},l("span",{text:"Pairing address"}),o),a),l("p",{className:"device-help",text:"Use your HTTPS tunnel address for phone links. Clear it to use this browser's address. This changes links only, not network exposure or origin permissions."}),l("p",{className:"device-help",text:"Pair each browser once. Access is remembered across tabs and browser restarts. "+(this.#e.ready?.expires_at?`This browser's access expires at ${new Date(this.#e.ready.expires_at*1e3).toLocaleString()}. `:"Access lasts until the bridge stops or this browser is revoked. ")+"Pairing links expire after five minutes."}),this.#f?this.#Q(this.#f):r,c);return l("div",{className:"device-overlay",on:{click:d=>{d.target===d.currentTarget&&this.#G()}}},h)}#Q(t){return l("div",{className:"pairing-card"},l("strong",{text:"New device link"}),l("p",{text:"Scan with the phone camera, or use the link below."}),l("div",{className:"pairing-qr-wrap"},l("img",{className:"pairing-qr",attrs:{src:fe(t),alt:"QR code containing the one-use Luvus device pairing link",width:"220",height:"220"}})),l("input",{className:"pairing-link",attrs:{value:t,readonly:"","aria-label":"One-use device pairing link"}}),l("div",{className:"pairing-actions"},b("Copy link","primary",()=>{this.#nt(t)}),typeof navigator.share=="function"?b("Share","ghost",()=>{navigator.share({title:"Connect to Luvus",url:t}).catch(()=>{})}):void 0,b("Done","ghost",()=>{this.#u=void 0,this.#f=void 0,this.#o(),this.#K()})))}async#K(){if(this.#s||this.#t.state!=="ready")return;this.#s=!0;let t;try{this.#r=P(await this.#e.request("web.devices.status")),this.#o()}catch(e){t=e}finally{this.#s=!1,this.#n&&this.#o()}t&&this.#N(t)}async#tt(){if(this.#s||!confirm("Disconnect this browser in every tab? You'll need a new QR/code to reconnect. Other browsers and running terminals are not affected."))return;this.#s=!0;const t=this.#P;try{await this.#e.request("web.devices.forget"),this.#$.clear(t),location.reload()}catch(e){this.#N(e)}finally{this.#s=!1,this.#o()}}async#z(t){if(this.#s)return;this.#s=!0;let e;try{this.#r=P(await this.#e.request("web.devices.set_limit",{limit:t})),this.#o()}catch(i){e=i}finally{this.#s=!1,this.#n&&this.#o()}e&&this.#N(e)}async#q(t){if(this.#s)return;this.#s=!0;let e;try{const i=t.trim();this.#r=P(await this.#e.request("web.devices.set_public_url",{url:i||null})),this.#u&&(this.#f=this.#I(this.#u));const n=this.root.querySelector(".device-url-input");n&&oi(n,t),this.#o()}catch(i){e=i}finally{this.#s=!1,this.#n&&this.#o()}e?this.#N(e):this.#V("Pairing address updated")}async#st(){if(this.#s)return;this.#s=!0,this.#o();let t;try{const e=hi(await this.#e.request("web.devices.create_pairing"));this.#r=e.devices,this.#u=e.code,this.#f=e.url??this.#I(e.code),this.#o()}catch(e){t=e}finally{this.#s=!1,this.#n&&this.#o()}t&&this.#N(t)}#I(t){return`${this.#r?.public_url??location.origin}/#pair=${encodeURIComponent(t)}`}async#nt(t){try{await navigator.clipboard.writeText(t),this.#V("Pairing link copied")}catch{document.querySelector(".pairing-link")?.select(),this.#V("Select and copy the pairing link")}}#G(){this.#n=!1,this.#o(),this.root.querySelector(this.#S.matches?'[data-view-key="mission-nav:devices"]':'[aria-label="Open navigation"]')?.focus({preventScroll:!0})}#C(){this.#v=!1,this.#i?.closeNavigation(),this.#n=!0,this.#o(),this.#d.querySelector(".device-close")?.focus({preventScroll:!0}),this.#K()}#rt(){this.#h=!0,this.#l=void 0,this.#o(),this.#Y()}#et(){this.#h=!1,this.#o()}async#Y(){if(this.#c||this.#t.state!=="ready")return;this.#c=!0,this.#o();let t;try{this.#l=di(await this.#e.request("web.sessions.list"))}catch(e){this.#h=!1,t=e}finally{this.#c=!1,this.#o()}t&&this.#N(t)}async#X(t){const e=this.#t.snapshot?.session;if(this.#c||t===e)return;this.#c=!0,this.#h=!1,this.#l=void 0,this.#o();let i;try{await this.#t.switchSession(t)}catch(n){i=n}finally{this.#c=!1,this.#o()}i?this.#N(i):this.#V(`Switched to ${t}`)}#it(t){const e=this.#t.capabilities?.access?.mode==="control";return l("div",{className:"session-menu",attrs:{role:"dialog","aria-label":"Switch Luvus session"}},l("div",{className:"session-menu-head"},l("strong",{text:"Switch session"}),b("Close","session-menu-close",()=>this.#et())),this.#c||!this.#l?l("p",{className:"session-menu-empty",text:"Loading sessions…"}):l("div",{className:"session-menu-list",attrs:{"data-scroll-key":"sessions"}},...this.#l.map(i=>{const n=i.name===t,r=n||!i.running&&!e;return l("button",{className:`session-option${n?" active":""}`,attrs:{type:"button",...r?{disabled:""}:{}},on:{click:()=>{this.#X(i.name)}}},l("span",{text:i.name}),l("small",{text:n?"Current":i.running?"Running":e?"Start":"Control required"}))})))}#W(){return l("nav",{className:"mission-dock",attrs:{"aria-label":"Devices and connection"}},ui("Devices","devices",()=>this.#C()),ge(this.#w.refresh),l("span",{className:`mission-nav-status ${this.#t.state}`,attrs:{role:"status","aria-label":`Connection ${this.#t.state}`,title:this.#t.state}},At("status")))}#H(){for(const t of this.root.querySelectorAll(".mission-nav-status"))t.className=`mission-nav-status ${this.#t.state}`,t.setAttribute("aria-label",`Connection ${this.#t.state}`),t.title=this.#t.state}#at(){this.#i||this.#S.matches||!this.#t.snapshot||this.#t.state==="expired"||(this.#v=!0,this.#h=!1,this.#o(),this.#k.focus({preventScroll:!0}))}#U(t=!1){this.#v&&(this.#v=!1,this.#o(),t&&!this.#S.matches&&this.#F.focus({preventScroll:!0}))}#ot(t){const e=this.#S.matches,i=e||this.#v,n=this.#p.resolve(t);this.#_.update(i?Nt(t,n?.pane):[]),this.#_.setFooter(this.#W()),this.#_.setCollapsed(this.#w.collapsed.workspaces),this.#b.update(i?this.#J():[],void 0,this.#m),this.#b.setCollapsed(this.#w.collapsed.panes);const{workspaces:r,panes:o}=this.#w.collapsed;this.#v?(this.#g.setAttribute("role","dialog"),this.#g.setAttribute("aria-modal","true")):(this.#g.removeAttribute("role"),this.#g.removeAttribute("aria-modal")),this.#F.setAttribute("aria-expanded",String(this.#v)),this.#T.inert=this.#v;const a=this.#Z(t);a.inert=this.#v;const c=l("div",{className:"web-content-frame"},this.#T,a);return!e&&this.#b.root.parentElement!==this.#g&&this.#g.append(this.#b.root),l("div",{className:`dashboard-layout${r?" workspaces-collapsed":""}${o?" panes-collapsed":""}${this.#v?" pane-navigation-open":""}`},this.#g,c,e?this.#b.root:void 0)}#Z(t){const e=t.workspaces.flatMap(a=>a.tabs.flatMap(c=>c.panes)).filter(a=>a.kind==="terminal"&&a.is_agent===!0),i=e.length,n=e.filter(a=>a.agent_status==="working").length,r=t.workspaces.reduce((a,c)=>a+c.tabs.length,0),o=t.workspaces.reduce((a,c)=>a+c.tabs.reduce((h,u)=>h+u.panes.length,0),0);return l("div",{className:"dashboard",attrs:{"data-scroll-key":"dashboard-main"}},l("section",{className:"mission-hero",attrs:{id:"mission-overview"}},l("div",{className:"hero-layout"},l("div",{className:"hero-stat-column stats-left"},I(String(t.workspaces.length).padStart(2,"0"),"Workspaces"),I(String(r).padStart(2,"0"),"Tabs")),l("div",{className:"hero-center"},l("h1",{text:"Live stats"}),l("div",{className:"mission-core",attrs:{"aria-label":"Luvus network online"}},l("div",{className:"orbit orbit-outer"}),l("div",{className:"orbit orbit-inner"}),l("div",{className:"core-mark"},l("img",{attrs:{src:"/mark.svg",alt:""}})),l("div",{className:"core-label"},l("strong",{text:"SYSTEM ONLINE"}),l("small",{text:`${n} executing`}))),l("div",{className:"hero-session-wrap"},l("button",{className:"hero-session",attrs:{type:"button","aria-haspopup":"dialog","aria-expanded":String(this.#h)},on:{click:()=>this.#h?this.#et():this.#rt()}},l("span",{text:"Session"}),l("strong",{text:t.session})),this.#h?this.#it(t.session):void 0)),l("div",{className:"hero-stat-column stats-right"},I(String(o).padStart(2,"0"),"Live panes"),I(String(i).padStart(2,"0"),"Agents")))))}#O(t,e){const i=this.#t.allowedMethods,n=i.has("terminal.backend.control");if(!n&&!i.has("terminal.backend.observe"))return;this.#v=!1,this.#p.select(t,e),this.#a.opened(t,e),this.#i?.destroy();const r=this.#t.capabilities?.terminal?.features?.includes("stream_cursor")??!1,o=n&&be(this.#t.capabilities?.terminal?.capabilities),a=_e(this.#t.capabilities?.terminal?.capabilities,n),c=new si(this.#e,t,e,n,o,r,a,()=>this.#J(),h=>{const u=this.#t.snapshot;u&&this.#O(u,h)},()=>{c.destroy(),this.#i=void 0,this.#o()},{showShells:this.#m,onChange:h=>{this.#m=h}},this.#w);this.#i=c,this.root.replaceChildren(c.root,this.#d),c.start().catch(h=>this.#N(h))}#J(){const t=this.#t.snapshot;return t?Pe(this.#a.options(t),this.#p.resolve(t)?.workspace):[]}#N(t){const e=t instanceof f||t instanceof Error?t.message:"Unexpected connection error",i=l("div",{className:"toast",text:e});this.root.append(i),setTimeout(()=>i.remove(),5e3)}#V(t){const e=l("div",{className:"toast success",text:t});this.root.append(e),setTimeout(()=>e.remove(),3e3)}}function ci(){const t=new URLSearchParams(location.hash.slice(1)).get("pair")||void 0;return t&&history.replaceState(null,"",`${location.pathname}${location.search}`),t}function P(s){const t=s;if(!t||t.type!=="browser_device_status"||!Number.isSafeInteger(t.paired_devices)||!Number.isSafeInteger(t.pending_pairings)||!Number.isSafeInteger(t.max_devices)||t.public_url!==null&&typeof t.public_url!="string"||t.limit_ceiling!==void 0&&!Number.isSafeInteger(t.limit_ceiling))throw new f("Invalid browser device status","invalid_response");return t}function hi(s){const t=s;if(!t||t.type!=="browser_device_pairing"||typeof t.code!="string"||!Number.isSafeInteger(t.expires_at)||!t.devices||t.url!==void 0&&typeof t.url!="string")throw new f("Invalid browser pairing response","invalid_response");return{...t,devices:P(t.devices)}}function di(s){const t=s;if(t?.type!=="browser_session_list"||!Array.isArray(t.sessions)||t.sessions.length>256)throw new f("Invalid browser session list","invalid_response");return t.sessions.map(e=>{const i=e;if(!i||typeof i.name!="string"||!i.name||typeof i.default!="boolean"||typeof i.running!="boolean")throw new f("Invalid browser session entry","invalid_response");return i})}function ui(s,t,e,i=!1,n=!1){return l("button",{className:`mission-nav-button${i?" active":""}`,attrs:{type:"button","aria-label":s,title:s,"data-view-key":`mission-nav:${t}`,...n?{disabled:""}:{}},on:{click:e}},At(t),l("span",{className:"mission-nav-label",text:s}))}function I(s,t){return l("div",{className:"mission-stat"},l("strong",{text:s}),l("div",{},l("span",{text:t})))}function At(s){const t={overview:["M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z"],workspaces:["M3 6.5h7l2 2h9v11H3z","M3 6.5V4h7l2 2"],devices:["M8 2h8a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z","M10 18h4"],status:["M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z","m8 12 2.5 2.5L16 9"],arrow:["M5 12h14m-5-5 5 5-5 5"]},e=document.createElementNS("http://www.w3.org/2000/svg","svg");e.setAttribute("class","mission-icon"),e.setAttribute("viewBox","0 0 24 24"),e.setAttribute("aria-hidden","true");for(const i of t[s]){const n=document.createElementNS("http://www.w3.org/2000/svg","path");n.setAttribute("d",i),n.setAttribute("fill",s==="overview"?"currentColor":"none"),n.setAttribute("stroke","currentColor"),n.setAttribute("stroke-width","1.5"),n.setAttribute("stroke-linecap","round"),n.setAttribute("stroke-linejoin","round"),e.append(n)}return e}const Ct=document.querySelector("#app");if(!Ct)throw new Error("Missing app root");const fi=new li(Ct);fi.start();pi().catch(()=>{});async function pi(){if(!("serviceWorker"in navigator))return;const s=await navigator.serviceWorker.getRegistrations();await Promise.all(s.map(async t=>{try{await t.update()}catch{await t.unregister()}}))} +No live terminal panes in this workspace`}`,r.button.setAttribute("aria-label",`Open workspace ${n.name}${n.branch?`, branch ${n.branch}`:""}, ${n.path}`),n.selected?r.button.setAttribute("aria-current","true"):r.button.removeAttribute("aria-current"),K(r.name,n.name),K(r.branch,n.branch),r.branch.hidden=!n.branch,K(r.path,n.path),r.root!==i&&this.#i.insertBefore(r.root,i),i=r.root.nextSibling}this.#r.hidden=t.length>0}#a(t){const e=l("strong",{className:"terminal-workspace-name"}),i=l("span",{className:"terminal-workspace-branch"}),n=l("span",{className:"terminal-workspace-path"}),r=l("button",{className:"terminal-workspace-label",attrs:{type:"button","data-view-key":`sidebar-workspace:${t}`},on:{click:()=>{a.option?.target&&this.onSelectPane(a.option.target)}}},l("span",{className:"terminal-workspace-copy"},l("span",{className:"terminal-workspace-line"},l("span",{className:"terminal-workspace-dot",attrs:{"aria-hidden":"true"}}),e,i),n)),a={option:void 0,root:l("section",{className:"terminal-workspace-group",attrs:{"data-sidebar-workspace":String(t)}},r),button:r,name:e,branch:i,path:n};return this.#s.set(t,a),a}}function K(s,t){s.textContent!==t&&(s.textContent=t)}const ze=32*1024*1024,at=160*1024,ot=new TextEncoder;async function De(s,t){if(!s.size||s.size>ze)throw new Error("Files must be between 1 byte and 32 MiB.");const e=await t("upload_start",{name:qe(s.name),size:s.size});if(e.type!=="terminal_upload"||!Ke(e.upload_id))throw new Error("The server did not create a terminal upload.");const i=e.upload_id,n=Number.isSafeInteger(e.max_chunk_bytes)&&Number(e.max_chunk_bytes)>0?Math.min(at,Number(e.max_chunk_bytes)):at;try{for(let r=0;r{}),r}}function qe(s){const t=s.split(/[\\/]/).at(-1)?.trim()||"attachment";if(ot.encode(t).byteLength<=255)return t;let e="";for(const i of t){if(ot.encode(e+i).byteLength>240)break;e+=i}return e||"attachment"}function Ke(s){return typeof s=="string"&&/^[0-9a-f]{32}$/.test(s)}function Ue(s){let t="";for(let e=0;e?',n=e.indexOf(t);return(n>=0?i[n]:/^[a-z]$/.test(t)?t.toUpperCase():t)+s.slice(1)}function lt(s){if(s.key==="Backspace"){if(s.metaKey)return"ctrl-u";if(s.altKey||s.ctrlKey)return"ctrl-w"}if(s.key==="Delete"){if(s.metaKey)return"ctrl-k";if(s.altKey||s.ctrlKey)return"alt-d"}if(!(s.metaKey||s.altKey)){if(s.ctrlKey&&!s.shiftKey){const t=s.key.toLowerCase();return We.has(t)?`ctrl-${t}`:void 0}if(!s.ctrlKey){if(s.key==="Tab")return s.shiftKey?"backtab":"tab";if(!s.shiftKey)return{Enter:"enter",Escape:"escape",Backspace:"backspace",Delete:"delete",ArrowUp:"up",ArrowDown:"down",ArrowLeft:"left",ArrowRight:"right",Home:"home",End:"end",PageUp:"pageup",PageDown:"pagedown"}[s.key]}}}function ct(s){return{deleteContentBackward:"backspace",deleteContentForward:"delete",deleteWordBackward:"ctrl-w",deleteWordForward:"alt-d",deleteSoftLineBackward:"ctrl-u",deleteHardLineBackward:"ctrl-u",deleteSoftLineForward:"ctrl-k",deleteHardLineForward:"ctrl-k"}[s]}const ht=256*1024,dt=new TextEncoder,F="​";class Ve{constructor(t,e,i,n=()=>{}){this.dispatch=t,this.onError=e,this.onFiles=i,this.onModifiersChanged=n,this.#m(),this.element.addEventListener("keydown",r=>this.#h(r)),this.element.addEventListener("beforeinput",r=>this.#c(r)),this.element.addEventListener("input",r=>this.#d(r)),this.element.addEventListener("compositionstart",()=>{this.#t=!0,this.element.setAttribute("data-empty","false")}),this.element.addEventListener("compositionend",r=>this.#b(r)),this.element.addEventListener("paste",r=>this.#l(r)),this.element.addEventListener("blur",()=>{this.clearModifiers(),this.#w()})}dispatch;onError;onFiles;onModifiersChanged;element=l("textarea",{className:"terminal-input-proxy",attrs:{"aria-label":"Terminal keyboard input",autocomplete:"off",autocapitalize:"off",autocorrect:"off",enterkeyhint:"enter",inputmode:"text",rows:"1",spellcheck:"false"}});#e="";#t=!1;#i=!1;#r;#n="";#s;#a={ctrl:!1,shift:!1};focus(){this.#i||this.element.focus({preventScroll:!0})}blur(){this.element.blur()}sendKey(t){const e=this.#a;this.clearModifiers();const i=e.ctrl||e.shift?U(t,e):t;if(!i){this.onError("This key does not support the selected terminal modifiers.");return}this.#f(i)}toggleModifier(t){this.#i||this.#t||(this.focus(),this.#a={...this.#a,[t]:!this.#a[t]},this.onModifiersChanged(this.#a))}clearModifiers(){!this.#a.ctrl&&!this.#a.shift||(this.#a={ctrl:!1,shift:!1},this.onModifiersChanged(this.#a))}#f(t){this.#w(),this.#k("send_key",{key:t}),this.focus()}destroy(){this.clearModifiers(),this.#i=!0,this.#e="",this.#r!==void 0&&cancelAnimationFrame(this.#r),this.#r=void 0,this.element.remove()}#h(t){if(t.isComposing||this.#t||(this.#s=void 0,(t.key==="Backspace"||t.key==="Delete")&&!t.ctrlKey&&!t.altKey&&!t.metaKey&&window.matchMedia("(pointer: coarse)").matches))return;const e=t.ctrlKey&&t.key.toLowerCase()==="c"&&(t.shiftKey||!!window.getSelection()?.toString());if(t.metaKey&&t.key!=="Backspace"&&t.key!=="Delete"||t.ctrlKey&&t.key.toLowerCase()==="v"||e){this.clearModifiers();return}if(t.altKey&&this.clearModifiers(),(this.#a.ctrl||this.#a.shift)&&!t.altKey&&!t.metaKey){const n=lt({...t,key:t.key,ctrlKey:!1,altKey:!1,metaKey:!1,shiftKey:!1})??(t.key===" "?"space":t.key),r=U(n,{ctrl:this.#a.ctrl||t.ctrlKey,shift:this.#a.shift||t.shiftKey});r&&(t.preventDefault(),this.clearModifiers(),this.#f(r));return}const i=lt(t);i&&(i==="ctrl-c"&&window.getSelection()?.toString()||(t.preventDefault(),this.sendKey(i)))}#c(t){if(t.isComposing||this.#t)return;if(this.#s=void 0,t.inputType==="insertLineBreak"||t.inputType==="insertParagraph"){if(!t.cancelable)return;t.preventDefault(),this.#m(),this.sendKey("enter");return}const e=ct(t.inputType);if(e){this.#s=t.inputType,this.sendKey(e);return}t.cancelable&&t.data&&t.inputType.startsWith("insert")&&(t.preventDefault(),this.#m(),this.#p(t.data,t.inputType==="insertFromPaste"))}#d(t){if(t.isComposing||this.#t)return;const e=this.#s!==void 0&&this.#s===t.inputType;this.#s=void 0;const i=t.data??this.#o();if(this.#m(),e)return;const n=ct(t.inputType);n?this.sendKey(n):t.inputType==="insertLineBreak"||t.inputType==="insertParagraph"?this.sendKey("enter"):this.#p(i,t.inputType==="insertFromPaste")}#m(){this.#s=void 0,this.element.value=F+F,this.element.setSelectionRange(1,1),this.element.setAttribute("data-empty","true")}#o(){let t=this.element.value;return t.startsWith(F)&&(t=t.slice(1)),t.endsWith(F)&&(t=t.slice(0,-1)),t}#p(t,e=!1){if(t){if(t===this.#n){this.#n="";return}this.#n="",e?this.#N(t):this.#S(t)}}#S(t,e=!1){const i=this.#a;if(this.clearModifiers(),i.ctrl){const n=/^[a-z ]$/i.test(t)?U(t===" "?"space":t,i):void 0;n?this.#f(n):e?this.#v(t):this.onError("Ctrl needs one letter or Space. Paste and IME input are sent unchanged.")}else this.#v(i.shift?He(t):t)}#b(t){this.#t=!1;const e=t.data||this.#o();if(this.#m(),!e){this.clearModifiers();return}this.#n=e,queueMicrotask(()=>{this.#n=""}),this.#S(e,!0)}#l(t){this.clearModifiers();const e=t.clipboardData,i=Array.from(e?.files??[]);if(!i.length)for(const r of Array.from(e?.items??[])){const o=r.kind==="file"?r.getAsFile():null;o&&i.push(o)}if(i.length){t.preventDefault(),this.#m(),this.#w(),this.onFiles(i);return}const n=t.clipboardData?.getData("text/plain")??"";n&&(t.preventDefault(),this.#m(),this.#N(n))}#N(t){if(this.clearModifiers(),this.#w(),dt.encode(t).byteLength>ht){this.onError("Paste is larger than the 256 KiB terminal input limit.");return}this.#k("paste_text",{text:t})}#v(t){this.#e+=t,this.#r===void 0&&(this.#r=requestAnimationFrame(()=>{this.#r=void 0,this.#w()}))}#w(){if(this.#r!==void 0&&cancelAnimationFrame(this.#r),this.#r=void 0,!this.#e)return;const t=this.#e;if(this.#e="",dt.encode(t).byteLength>ht){this.onError("Input is larger than the 256 KiB terminal input limit.");return}this.#k("type_literal",{text:t})}#k(t,e){this.#i||this.dispatch(t,e).catch(i=>{if(this.#i)return;const n=i instanceof Error?i.message:"Terminal input failed";this.onError(n)})}}const je=new Set(["bridge_error","closed","disconnected","stale_server","stale_stream","timeout","unavailable"]);function ut(s,t=4){return s instanceof p?s.code==="control_conflict"?t<4:je.has(s.code):!0}const Ge=["#181922","#ed8f9e","#9ed68a","#ead778","#90b9f2","#c6a0f6","#7bdff2","#d9d7e7","#676579","#f3a7b4","#b5e8a4","#f1e38f","#a9c9f7","#d5b7fa","#9ae8f5","#f4f1ff"];function Ye(s){const t=[],e=It(),i=/\x1b\[([0-9;]*)m/g;let n=0;for(const r of s.matchAll(i)){const o=r.index??n;pt(t,ft(s.slice(n,o)),e),Je(e,r[1]?r[1].split(";").map(Number):[0]),n=o+r[0].length}return pt(t,ft(s.slice(n)),e),t}function It(){return{foreground:void 0,background:void 0,bold:!1,dim:!1,italic:!1,underline:!1,reverse:!1,hidden:!1,strike:!1}}function Xe(s){Object.assign(s,It(),{foreground:void 0,background:void 0})}function ft(s){return s.replace(/\r\n?/g,` +`).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g,"")}function pt(s,t,e){if(!t)return;const i=Ze(e);s.push({text:t,...i?{style:i}:{}})}function Ze(s){let t=s.foreground,e=s.background;s.reverse&&([t,e]=[e??"#d9d7e7",t??"#111219"]);const i={};return t&&(i.color=t),e&&(i.backgroundColor=e),s.bold&&(i.fontWeight="700"),s.dim&&(i.opacity="0.65"),s.italic&&(i.fontStyle="italic"),(s.underline||s.strike)&&(i.textDecoration=[s.underline?"underline":"",s.strike?"line-through":""].filter(Boolean).join(" ")),s.hidden&&(i.visibility="hidden"),Object.keys(i).length?i:void 0}function Je(s,t){for(let e=0;e=30&&i<=37)s.foreground=P(i-30);else if(i>=90&&i<=97)s.foreground=P(i-90+8);else if(i===39)s.foreground=void 0;else if(i>=40&&i<=47)s.background=P(i-40);else if(i>=100&&i<=107)s.background=P(i-100+8);else if(i===49)s.background=void 0;else if(i===38||i===48){const n=Qe(t,e+1);n&&(i===38?s.foreground=n.value:s.background=n.value,e+=n.consumed)}}}function Qe(s,t){if(s[t]===5&&Number.isInteger(s[t+1]))return{value:P(s[t+1]),consumed:2};if(s[t]===2&&[s[t+1],s[t+2],s[t+3]].every(Number.isInteger)){const[e,i,n]=s.slice(t+1,t+4).map(ti);return{value:`rgb(${e}, ${i}, ${n})`,consumed:4}}}function P(s){const t=Math.max(0,Math.min(255,s));if(t<16)return Ge[t];if(t<232){const i=t-16,n=r=>r===0?0:55+r*40;return`rgb(${n(Math.floor(i/36))}, ${n(Math.floor(i%36/6))}, ${n(i%6)})`}const e=8+(t-232)*10;return`rgb(${e}, ${e}, ${e})`}function ti(s){return Math.max(0,Math.min(255,s))}function ei(s,t,e=0){const i=[];let n=t,r=!1;const o=(a,c)=>{a&&i.push({kind:"text",text:a,...c?{style:c}:{}})};for(const a of Ye(s)){const c=Array.from(a.text);!r&&n!==void 0&&n<=c.length?(o(c.slice(0,n).join(""),a.style),o(" ".repeat(e)),i.push({kind:"cursor"}),o(c.slice(n).join(""),a.style),r=!0):(o(a.text,a.style),!r&&n!==void 0&&(n-=c.length))}return!r&&n===0&&(o(" ".repeat(e)),i.push({kind:"cursor"})),i}function ii(s,t){const e=s.ownerDocument,i=e.getSelection();let n;if(i&&!i.isCollapsed&&i.anchorNode&&i.focusNode&&s.contains(i.anchorNode)&&s.contains(i.focusNode)){const o=e.createRange();o.selectNodeContents(s),o.setEnd(i.anchorNode,i.anchorOffset);const a=o.toString().length;o.setEnd(i.focusNode,i.focusOffset),n={text:s.textContent??"",anchor:a,focus:o.toString().length}}let r=s.firstChild;for(const o of t){const a=o.kind==="cursor"?"terminal-caret":o.style?"terminal-run":void 0;let c=r;if((a?c instanceof HTMLElement&&c.className===a:c instanceof Text)||(c=a?l("span",{className:a}):e.createTextNode(""),o.kind==="cursor"&&c.setAttribute("aria-hidden","true"),r?s.replaceChild(c,r):s.append(c)),o.kind==="text"){let u;if(c instanceof Text)u=c;else{const d=c,m=JSON.stringify(o.style);d.dataset.terminalStyle!==m&&(d.style.cssText="",Object.assign(d.style,o.style),d.dataset.terminalStyle=m),d.firstChild instanceof Text||d.append(e.createTextNode("")),u=d.firstChild}ni(u,o.text)}r=c.nextSibling}for(;r;){const o=r.nextSibling;s.removeChild(r),r=o}if(n&&i){const o=n.anchor>n.focus,a=si(n.text,s.textContent??"",Math.min(n.anchor,n.focus),Math.max(n.anchor,n.focus)),c=a&&mt(s,a[0]),h=a&&mt(s,a[1]);if(c&&h){const u=o?h:c,d=o?c:h;(i.anchorNode!==u.node||i.anchorOffset!==u.offset||i.focusNode!==d.node||i.focusOffset!==d.offset)&&i.setBaseAndExtent(u.node,u.offset,d.node,d.offset)}else i.removeAllRanges()}}function si(s,t,e,i){if(e<0||i>s.length||e>=i)return;let n=0;for(;n=s.length-r)return[e+t.length-s.length,i+t.length-s.length];const o=s.slice(e,i),a=t.indexOf(o);if(a<0)return;if(t.indexOf(o,a+1)<0&&s.indexOf(o)===e&&s.indexOf(o,e+1)<0)return[a,a+o.length];const c=Math.max(0,e-32),h=s.slice(c,i+32),u=t.indexOf(h);if(u<0||t.indexOf(h,u+1)>=0||s.indexOf(h)!==c||s.indexOf(h,c+1)>=0)return;const d=u+e-c;return[d,d+o.length]}function mt(s,t){const e=s.ownerDocument.createTreeWalker(s,NodeFilter.SHOW_TEXT);for(;e.nextNode();){const i=e.currentNode;if(t<=i.length)return{node:i,offset:t};t-=i.length}}function ni(s,t){const e=s.data;if(e===t)return;let i=0;for(;i=0)return;let r=n+e.contextOffset;const o=t.ownerDocument,a=o.createTreeWalker(t,NodeFilter.SHOW_TEXT);for(;a.nextNode();){const c=a.currentNode;if(r>=c.length){r-=c.length;continue}const h=o.createRange();h.setStart(c,r),h.setEnd(c,r+1);const u=h.getClientRects()[0];u&&(s.scrollTop+=u.top-e.top);return}}class oi{session;serverGeneration;#e;#t;constructor(t,e){if(!e.terminal_id||!gt(t,e.pane_id,e.terminal_id))throw new Error("Pane has no live terminal route");this.session=t.session,this.serverGeneration=t.server_generation,this.#e=e.pane_id,this.#t=e.terminal_id}resolve(t){if(t.session!==this.session||t.server_generation!==this.serverGeneration)return;const e=gt(t,this.#e,this.#t);if(e)return this.#e=e.pane_id,this.#t=e.terminal_id,{serverGeneration:t.server_generation,pane:e}}}function gt(s,t,e){if(e)for(const i of s.workspaces)for(const n of i.tabs){const r=n.panes.find(o=>o.kind==="terminal"&&o.pane_id===t&&o.terminal_id===e);if(r)return r}}class li{constructor(t,e,i,n,r,o,a,c,h,u,d,m,b){this.bridge=t,this.control=n,this.canUploadFiles=r,this.streamCursor=o,this.viewportSizing=a,this.modifiedKeys=c,this.paneOptions=h,this.onSelectPane=u,this.sidebarControls=b,this.#r=e,this.#i=new oi(e,i),this.#t=this.#i.resolve(e),n?(this.#l=new Ve((f,v)=>this.#Z(f,v),f=>this.#ot(f),f=>{this.canUploadFiles&&this.#V(f)},f=>{for(const w of["ctrl","shift"])this.#N[w]?.setAttribute("aria-pressed",String(f[w]));const v=[f.ctrl?"Ctrl":"",f.shift?"Shift":""].filter(Boolean).join(" + ");this.#v.textContent=v?`${v} · next key`:"Type or hold to paste"}),this.#l.element.addEventListener("focus",()=>this.#R(!0)),this.#l.element.addEventListener("focus",()=>{this.#w.textContent="Typing in terminal",this.root.classList.add("keyboard-active"),this.#_=!0,this.#j(!0)}),this.#l.element.addEventListener("blur",()=>{this.#w.textContent="Tap terminal to type",this.root.classList.remove("keyboard-active"),this.#j()}),this.#o.addEventListener("click",()=>{window.getSelection()?.toString()||this.#l?.focus()})):this.#w.textContent="Read-only terminal";const g=l("input",{className:"terminal-file-input",attrs:{type:"file",multiple:"","aria-label":"Attach files"}});g.disabled=!r;const S=y("+","terminal-tool attach-file",()=>{this.canUploadFiles&&g.click()});this.#k=S,S.disabled=!r,S.setAttribute("aria-label","Attach files"),S.title="Attach files",g.addEventListener("change",()=>{const f=Array.from(g.files??[]);g.value="",f.length&&this.#V(f)}),r&&(this.root.addEventListener("dragenter",f=>this.#tt(f)),this.root.addEventListener("dragover",f=>this.#tt(f)),this.root.addEventListener("dragleave",f=>{(!(f.relatedTarget instanceof Node)||!this.root.contains(f.relatedTarget))&&this.root.classList.remove("file-drag-active")}),this.root.addEventListener("drop",f=>{const v=Array.from(f.dataTransfer?.files??[]);v.length&&(f.preventDefault(),this.root.classList.remove("file-drag-active"),this.#V(v))}));const x=y("⌨","terminal-tool keyboard-toggle",()=>{document.activeElement===this.#l?.element?this.#l.blur():this.#l?.focus()});x.disabled=!n,x.setAttribute("aria-label","Show or hide keyboard"),x.title="Keyboard",x.addEventListener("pointerdown",f=>f.preventDefault());const C=["ctrl","shift"].map(f=>{const v=f==="ctrl"?"Ctrl":"Shift",w=y(v,"terminal-tool terminal-modifier",()=>this.#l?.toggleModifier(f));return w.disabled=!n||!c,w.setAttribute("aria-label",f==="ctrl"?"Control modifier":"Shift modifier"),w.setAttribute("aria-pressed","false"),w.title=c?`${v} — next key`:"Update the Luvus server to use terminal modifiers",w.addEventListener("pointerdown",D=>D.preventDefault()),this.#N[f]=w,w}),Ot=[["escape","Esc"],["tab","Tab"],["enter","↵"],["up","↑"],["down","↓"],["left","←"],["right","→"],["ctrl-c","⌃C"]].map(([f,v])=>{const w=y(v,"terminal-tool",()=>this.#l?.sendKey(f));return w.disabled=!n,w.setAttribute("aria-label",f==="ctrl-c"?"Control C":f),w.addEventListener("pointerdown",D=>D.preventDefault()),w}),M=l("div",{className:"terminal-tools"},S,x,...C,...Ot);M.inert=!0,M.setAttribute("aria-hidden","true");const T=y("","terminal-controls-toggle",()=>{const f=this.root.classList.toggle("controls-expanded");M.inert=!f,M.setAttribute("aria-hidden",String(!f)),f||this.#l?.clearModifiers(),T.setAttribute("aria-expanded",String(f)),T.setAttribute("aria-label",f?"Hide terminal controls":"Show terminal controls"),T.title=f?"Hide terminal controls":"Show terminal controls",this.#_&&this.#K(),this.#R()});T.setAttribute("aria-expanded","false"),T.setAttribute("aria-label","Show terminal controls"),T.title="Show terminal controls",T.addEventListener("pointerdown",f=>f.preventDefault()),this.#q=l("button",{className:"terminal-pane-selector",attrs:{type:"button","aria-label":"Open navigation",title:"Open navigation","aria-haspopup":"dialog","aria-expanded":"false","aria-controls":"terminal-navigation"},on:{click:()=>this.#st()}},l("span",{className:"terminal-pane-dot",attrs:{"aria-hidden":"true"}}));const Bt=l("div",{className:"terminal-pane-switcher"},this.#q),tt=f=>{const v=this.#M;this.#I(v),f.pane_id===this.#t?.pane.pane_id&&f.terminal_id===this.#t?.pane.terminal_id?v||this.#l?.focus():this.onSelectPane(f)};this.#A=new Pt(tt,m,()=>this.#I(!0),()=>b.toggle("panes")),this.#A.closeButton.setAttribute("aria-label","Close navigation"),this.#A.closeButton.setAttribute("aria-controls","terminal-navigation"),this.#A.closeButton.title="Close navigation",this.#O=new $t(tt,d,()=>b.toggle("workspaces")),this.#O.setFooter(b.footer()),this.#$.append(l("header",{className:"terminal-navigation-header"},R(d),this.#A.closeButton),this.#O.root,this.#A.root),this.updateSidebarLayout(),this.#D(),this.#B(),this.#u=l("header",{className:"terminal-header"},R(d),Bt),this.#T=l("div",{className:"terminal-main"},this.#o,l("div",{className:"terminal-controls-wrap"},this.#l?l("div",{className:"terminal-input-surface"},this.#l.element,this.#v):void 0,l("div",{className:"terminal-controls"},this.#w,M,T))),this.#Y.append(this.#u,this.#T),this.root.append(this.#$,this.#Y,g),this.#o.append(this.#W),this.#X(),this.viewportSizing&&(this.#P=new ResizeObserver(()=>{this.#_&&this.#K(),this.#R()}),this.#P.observe(this.#o)),this.#o.addEventListener("scroll",()=>{const f=this.#o.scrollHeight-this.#o.scrollTop-this.#o.clientHeight;this.#_=f<80},{passive:!0}),window.visualViewport?.addEventListener("resize",this.#y),window.visualViewport?.addEventListener("scroll",this.#y),window.addEventListener("resize",this.#y),document.addEventListener("keydown",this.#J),this.#L.addEventListener("change",this.#H),this.#j()}bridge;control;canUploadFiles;streamCursor;viewportSizing;modifiedKeys;paneOptions;onSelectPane;sidebarControls;root=l("section",{className:"terminal-screen"});#e;#t;#i;#r;#n=0;#s;#a=0;#f;#h=!1;#c=[];#d;#m=l("div",{className:"terminal-content"});#o=l("div",{className:"terminal-output",attrs:{role:"log",tabindex:"0","aria-label":"Terminal output"}},this.#m);#p;#S;#b;#l;#N={};#v=l("span",{className:"terminal-input-placeholder",text:"Type or hold to paste",attrs:{"aria-hidden":"true"}});#w=l("span",{className:"terminal-input-hint",text:"Tap terminal to type"});#k;#z=Promise.resolve();#_=!0;#g;#y=()=>{this.#j(),this.#R()};#W=l("span",{className:"terminal-cell-probe",attrs:{"aria-hidden":"true"},text:"0000000000"});#F;#C;#P;#T;#u;#Y=l("div",{className:"web-content-frame"});#$=l("div",{className:"terminal-navigation",attrs:{id:"terminal-navigation","aria-label":"Workspaces and panes"}});#M=!1;#q;#A;#O;#L=matchMedia("(min-width: 1024px)");#H=()=>{this.#L.matches&&this.#I(),this.#X(),this.#D(),this.#B()};#J=t=>{this.#M&&(t.key==="Escape"?(t.preventDefault(),this.#I(!0)):At(t,this.#$))};async start(){if(!this.#t)throw new Error("Pane has no live terminal identity");await this.#Q(!0),this.control&&!this.#M&&matchMedia("(pointer: fine)").matches&&this.#l?.focus()}async#Q(t=!1){if(this.#h)return;const e=this.#t;if(!e?.pane.terminal_id){if(t)throw new Error("Pane has no live terminal identity");return}this.#s&&clearTimeout(this.#s),this.#s=void 0;const i=++this.#n,n=this.control?"terminal.backend.control":"terminal.backend.observe";try{await this.bridge.connect();const r=await this.bridge.openStream(n,{server_generation:e.serverGeneration,terminal_id:e.pane.terminal_id,pane_id:e.pane.pane_id,mode:"recent_unwrapped",lines:120,ansi:!0,...this.streamCursor?{cursor:!0}:{}},o=>this.#nt(o,i),o=>this.#et(i,o));if(this.#h||i!==this.#n){r.close();return}this.#e=r,this.#a=0,this.#at(),this.#ct(),this.#G(r),this.#F=void 0,this.#R(!0)}catch(r){if(this.#h||i!==this.#n)return;if(this.#e=void 0,!ut(r,this.#a)){if(this.#it(r),t)throw r;this.#E(r instanceof Error?r.message:"Terminal connection failed");return}this.#E(r instanceof p&&r.code==="control_conflict"?"Waiting for terminal control…":"Connection interrupted — reconnecting…"),this.#x()}}destroy(){this.#h=!0,this.#n+=1,this.#s&&clearTimeout(this.#s),this.#s=void 0,this.#it(new p("Terminal view closed","closed")),this.#l?.destroy(),this.#e?.close(),this.#e=void 0,this.#p!==void 0&&cancelAnimationFrame(this.#p),this.#g!==void 0&&cancelAnimationFrame(this.#g),this.#C!==void 0&&clearTimeout(this.#C),this.#P?.disconnect(),window.visualViewport?.removeEventListener("resize",this.#y),window.visualViewport?.removeEventListener("scroll",this.#y),window.removeEventListener("resize",this.#y),document.removeEventListener("keydown",this.#J),this.#L.removeEventListener("change",this.#H)}updateSnapshot(t){if(this.#h)return;if(t.session!==this.#i.session){this.#I(),this.#A.update([],void 0),this.#O.update([]),this.#r=t,this.#t=void 0,this.#s&&clearTimeout(this.#s),this.#s=void 0;const o=this.#e;this.#n+=1,this.#e=void 0,o?.close(),this.#f=new p("Terminal belongs to another session","stale_stream"),this.#it(this.#f),this.#E("Session changed — return to Mission Control to choose a terminal");return}const e=this.#t;this.#r=t;const i=t.server_generation!==this.#i.serverGeneration,n=this.#i.resolve(t);if(this.#t=n,this.#D(),this.#B(),!n){this.#s&&clearTimeout(this.#s),this.#s=void 0;const o=this.#e;this.#n+=1,this.#e=void 0,o?.close(),i?(this.#f=new p("The server restarted; choose the terminal again before sending input","stale_server"),this.#it(this.#f),this.#E("Server restarted — return to Mission Control and choose the terminal again")):this.#E("Waiting for terminal to restore…");return}this.#f=void 0,(!e||e.serverGeneration!==n.serverGeneration||e.pane.pane_id!==n.pane.pane_id||e.pane.terminal_id!==n.pane.terminal_id)&&(this.#a=0,this.#e?this.#U(this.#n,"Terminal identity changed"):(this.#n+=1,this.#s&&clearTimeout(this.#s),this.#s=void 0,this.#x(!0)))}updateTitles(t){this.#h||!this.#L.matches&&!this.#M||this.#D()}updateSidebarLayout(){const{workspaces:t,panes:e}=this.sidebarControls.collapsed;this.root.classList.toggle("workspaces-collapsed",t),this.root.classList.toggle("panes-collapsed",e),this.#O.setCollapsed(t),this.#A.setCollapsed(e)}closeNavigation(){this.#I()}#X(){const t=this.#L.matches?this.root:this.#$;this.#A.root.parentElement!==t&&t.append(this.#A.root)}#D(t){this.#A.update(this.#L.matches||this.#M?t??this.paneOptions():[],this.#t?.pane)}#B(){this.#O.update((this.#L.matches||this.#M)&&this.#r.session===this.#i.session?Lt(this.#r,this.#t?.pane):[])}#st(){this.#h||this.#L.matches||(this.#M=!0,this.#l?.blur(),this.#u.inert=!0,this.#T.inert=!0,this.#l&&(this.#l.element.inert=!0),this.root.classList.add("pane-navigation-open"),this.#$.setAttribute("role","dialog"),this.#$.setAttribute("aria-modal","true"),this.#q?.setAttribute("aria-expanded","true"),this.#D(),this.#B(),this.#A.closeButton.focus({preventScroll:!0}))}#I(t=!1){this.#M&&(this.#M=!1,this.root.classList.remove("pane-navigation-open"),this.#$.removeAttribute("role"),this.#$.removeAttribute("aria-modal"),this.#u.inert=!1,this.#T.inert=!1,this.#l&&(this.#l.element.inert=!1),this.#q?.setAttribute("aria-expanded","false"),this.#D(),this.#B(),t&&!this.#L.matches&&this.#q?.focus({preventScroll:!0}))}#nt(t,e){if(this.#h||e!==this.#n)return;if(t.event==="terminal.resync_required"){this.#U(e,"Terminal changed while the connection was catching up");return}if(t.event!=="terminal.frame")return;const i=t;if(typeof i.data.text=="string"){const n=i.data.cursor?.offset,r=i.data.cursor?.padding_cells;this.#rt(i.data.text,typeof n=="number"&&Number.isSafeInteger(n)&&n>=0?n:void 0,typeof r=="number"&&Number.isSafeInteger(r)&&r>=0?r:0)}}#rt(t,e,i=0){this.#S={text:t,cursorOffset:e,cursorPadding:i},this.#p===void 0&&(this.#p=requestAnimationFrame(()=>{this.#p=void 0;const n=this.#S;if(this.#S=void 0,n===void 0||n.text===this.#b?.text&&n.cursorOffset===this.#b.cursorOffset&&n.cursorPadding===this.#b.cursorPadding)return;const r=this.#_,o=r?void 0:ri(this.#o,this.#m);ii(this.#m,ei(n.text,n.cursorOffset,n.cursorPadding)),this.#b=n,r?this.#K():o&&ai(this.#o,this.#m,o)}))}#E(t){this.#d?.isConnected||(this.#d=l("div",{className:"terminal-status terminal-connection-status"}),this.#o.append(this.#d)),this.#d.textContent=t,this.#o.scrollTop=this.#o.scrollHeight}#at(){this.#d?.remove(),this.#d=void 0}#ot(t){if(!this.#e){this.#E("Connection interrupted — reconnecting…"),this.#x();return}this.#E(`Input was not delivered: ${t}`)}async#Z(t,e){if(this.#_=!0,this.#K(),this.#f)throw this.#f;const i=this.#e;if(!i){if(this.#h)throw new p("Terminal view is closed","closed");if(this.#c.length>=256)throw new p("Terminal reconnect input buffer is full","input_buffer_full");return this.#x(),new Promise((n,r)=>{this.#c.push({action:t,params:e,resolve:n,reject:r})})}try{return await i.action(t,e)}catch(n){throw this.#e===i&&ut(n)&&this.#U(this.#n,"Terminal input connection was interrupted"),n}}#V(t){this.canUploadFiles&&(this.#z=this.#z.then(async()=>{if(t.length){this.#k&&(this.#k.disabled=!0),this.#w.textContent=`Uploading ${t.length===1?t[0].name:`${t.length} files`}`;try{for(let e=0;ethis.#Z(i,n)),e+1{this.#C=void 0,this.#lt(t)},150))}async#lt(t){const e=this.#e;if(!e||this.#h)return;const i=getComputedStyle(this.#o),n=this.#W.getBoundingClientRect(),r=Me(this.#o.clientWidth-parseFloat(i.paddingLeft)-parseFloat(i.paddingRight),this.#o.clientHeight-parseFloat(i.paddingTop)-parseFloat(i.paddingBottom),n.width/10,n.height);if(!(!r||!t&&Le(r,this.#F))){this.#F=r;try{await e.action("set_viewport",{cols:r.cols,rows:r.rows})}catch{this.#F=void 0}}}#K(){this.#o.scrollTop=this.#o.scrollHeight,this.#g!==void 0&&cancelAnimationFrame(this.#g),this.#g=requestAnimationFrame(()=>{this.#g=void 0,this.#_&&(this.#o.scrollTop=this.#o.scrollHeight)})}#et(t,e){this.#h||t!==this.#n||(this.#n+=1,this.#e=void 0,this.#E(e?"Connection interrupted — reconnecting…":"Reconnecting terminal…"),this.#x())}#U(t,e){if(this.#h||t!==this.#n)return;const i=this.#e;this.#n+=1,this.#e=void 0,i?.close(),this.#E("Connection interrupted — reconnecting…"),this.#x()}#x(t=!1){if(this.#h||this.#e||this.#s)return;const e=t?0:Math.min(5e3,150*2**Math.min(this.#a++,5));this.#s=setTimeout(()=>{this.#s=void 0,this.#Q()},e)}#G(t){const e=this.#c;this.#c=[];for(const i of e)t.action(i.action,i.params).then(i.resolve,i.reject)}#it(t){const e=this.#c;this.#c=[];for(const i of e)i.reject(t)}#ct(){this.control?document.activeElement===this.#l?.element?this.#w.textContent="Typing in terminal":this.#w.textContent="Tap terminal to type":this.#w.textContent="Read-only terminal"}}function bt(s,t){const e=s.ownerDocument.activeElement,i=e&&e!==s&&s.contains(e)?{tag:e.tagName,...W(s,e)}:void 0,n=i&&G(e)?hi(e):void 0,r=new Map;for(const a of vt(s))if(!a.readOnly&&a.value!==a.defaultValue){const c=W(s,a);r.set(`${c.key}#${c.index}`,a.value)}const o=new Map;for(const a of Array.from(s.querySelectorAll("[data-scroll-key]")))(a.scrollTop||a.scrollLeft)&&o.set(a.dataset.scrollKey??"",[a.scrollTop,a.scrollLeft]);if(t(),r.size)for(const a of vt(s)){const c=W(s,a),h=r.get(`${c.key}#${c.index}`);h!==void 0&&!a.readOnly&&(a.value=h)}for(const a of Array.from(s.querySelectorAll("[data-scroll-key]"))){const c=o.get(a.dataset.scrollKey??"");c&&([a.scrollTop,a.scrollLeft]=c)}if(i){const a=Array.from(s.querySelectorAll(i.tag)).filter(c=>j(c)===i.key)[i.index];a&&!a.hasAttribute("disabled")&&(a.focus({preventScroll:!0}),n&&G(a)&&di(a,n))}}function W(s,t){const e=j(t),i=Array.from(s.querySelectorAll(t.tagName)).filter(n=>j(n)===e);return{key:e,index:Math.max(0,i.indexOf(t))}}function j(s){const t=s.dataset.viewKey;if(t)return`key:${t}`;const e=s.getAttribute("aria-label")??s.getAttribute("name")??"",i=s.tagName==="BUTTON"?(s.textContent??"").trim().slice(0,80):"";return`${s.tagName}.${s.className}|${e}|${i}`}const ci=new Set(["text","url","search","tel","password",""]);function vt(s){return Array.from(s.querySelectorAll("input, textarea")).filter(G)}function G(s){return s?s.tagName==="TEXTAREA"?!0:s.tagName==="INPUT"&&ci.has(s.type):!1}function hi(s){try{return[s.selectionStart,s.selectionEnd,s.selectionDirection]}catch{return}}function di(s,[t,e,i]){if(!(t===null||e===null))try{s.setSelectionRange(t,e,i??void 0)}catch{}}function ui(s,t){const e=s.value;s.defaultValue=t,s.value=e}class fi{constructor(t){this.root=t;const e=r=>{const o=this.#t.snapshot;o&&this.#et(o,r)},i=()=>{this.#R(!0),this.root.querySelector(".dashboard")?.scrollTo({top:0,behavior:"smooth"}),window.scrollTo({top:0,behavior:"smooth"})};this.#S=new $t(e,i,()=>this.#g.toggle("workspaces")),this.#b=new Pt(e,{showShells:this.#m,onChange:r=>{this.#m=r,this.#u()}},()=>this.#R(!0),()=>this.#g.toggle("panes")),this.#k=this.#b.closeButton,this.#k.setAttribute("aria-label","Close navigation"),this.#k.setAttribute("aria-controls","dashboard-navigation"),this.#k.title="Close navigation",this.#w.setAttribute("aria-haspopup","dialog"),this.#z=l("header",{className:"dashboard-header"},R(i),this.#w),this.#N.append(l("header",{className:"terminal-navigation-header"},R(i),this.#k),this.#S.root,this.#b.root),this.#_.addEventListener("change",()=>{this.#_.matches&&(this.#v=!1),this.#u()}),document.addEventListener("keydown",r=>{const o=this.#n?this.#l.querySelector(".device-panel"):!this.#i&&this.#v?this.#N:void 0;if(!o){r.key==="Escape"&&this.#c&&(r.preventDefault(),this.#E(!0));return}r.key==="Escape"?(r.preventDefault(),this.#n?this.#I():this.#R(!0)):At(r,o)}),document.addEventListener("click",r=>{this.#c&&(r.composedPath().some(o=>o instanceof HTMLElement&&o.classList.contains("hero-session-wrap"))||this.#E())}),t.addEventListener("pointerdown",r=>this.#T.hold(r.pointerId),!0);for(const r of["pointerup","pointercancel"])window.addEventListener(r,o=>this.#T.release(o.pointerId),!0);window.addEventListener("pointermove",r=>{r.buttons===0&&this.#T.release(r.pointerId)},!0),window.addEventListener("blur",()=>this.#T.releaseAll()),document.addEventListener("visibilitychange",()=>{document.hidden&&this.#T.releaseAll()}),this.#y=pi();const n=location.protocol==="https:"?"wss:":"ws:";this.#e=new zt(`${n}//${location.host}/bridge`,()=>{this.#P=this.#C.get();const r=Gt(this.#P,this.#y);return this.#W={ticket:!!r.ticket,code:!!r.code},r},r=>{this.#C.set(r),this.#P=r,this.#y=void 0}),this.#e.addEventListener("devices",r=>{try{this.#r=$(r.detail),this.#a&&(this.#f=this.#B(this.#a)),this.#T.request()}catch(o){this.#x(o)}}),this.#t=new Kt(this.#e),this.#t.addEventListener("state",()=>{this.#t.state==="ready"&&(this.#y=void 0),this.#t.state==="expired"&&this.#i&&(this.#i.destroy(),this.#i=void 0),this.#T.request(),this.#t.state==="ready"&&!this.#r&&this.#H()}),this.#t.addEventListener("snapshot",()=>{const r=this.#t.snapshot;r&&this.#i?.updateSnapshot(r),this.#T.request()}),this.#t.addEventListener("titles",r=>this.#$(r.detail)),window.addEventListener("storage",r=>{(r.key===E||r.key===null)&&this.#C.get()!==this.#P&&location.reload()})}root;#e;#t;#i;#r;#n=!1;#s=!1;#a;#f;#h;#c=!1;#d=!1;#m=!1;#o=new Re;#p=new Be;#S;#b;#l=l("div",{className:"web-overlays"});#N=l("div",{className:"terminal-navigation",attrs:{id:"dashboard-navigation","aria-label":"Workspaces and panes"}});#v=!1;#w=ke("Open navigation","dashboard-navigation",()=>this.#j());#k;#z;#_=matchMedia("(min-width: 1024px)");#g={collapsed:{workspaces:!1,panes:!1},toggle:t=>{this.#g.collapsed[t]=!this.#g.collapsed[t],this.#i?this.#i.updateSidebarLayout():this.#u()},refresh:()=>{this.#t.refresh().catch(t=>this.#x(t))},footer:()=>this.#V()};#y;#W={ticket:!1,code:!1};#F;#C=new jt;#P=null;#T=new ye(()=>this.#Y());async start(){this.#u();try{await this.#t.start()}catch(t){this.#t.state!=="expired"&&this.#x(t)}}#u(){this.#T.holding?this.#T.request():this.#Y()}#Y(){if(this.#i){this.#i.root.inert=this.#n,this.#tt(),bt(this.#l,()=>this.#l.replaceChildren(...this.#n?[this.#O()]:[]));return}const t=this.#t.snapshot,e=this.#t.state==="expired";e&&(this.#v=!1,this.#n=!1),bt(this.root,()=>{this.#l.replaceChildren(...!e&&t&&this.#n?[this.#O()]:[]),this.root.replaceChildren(l("div",{className:"shell"},e?l("div",{className:"dashboard loading-dashboard"},this.#q()):t?this.#lt(t):l("div",{className:"dashboard loading-dashboard"},this.#M()),this.#l))});const i=this.root.querySelector(".dashboard-layout");i&&(i.inert=this.#n)}#$(t){if(this.#t.snapshot){if(this.#i){this.#i.updateTitles(t);return}(this.#_.matches||this.#v)&&this.#t.state!=="expired"&&this.#b.update(this.#U(),void 0,this.#m)}}#M(){return l("section",{className:"empty-state"},l("div",{className:"pulse"}),l("h1",{text:"Connecting to Luvus"}),l("p",{text:"Authenticating and reconciling the live session."}))}#q(){const t=Zt(this.#W),e=l("input",{className:"pair-input",attrs:{type:"text",placeholder:"Paste a pairing link","aria-label":"Pairing link or code",autocomplete:"off",autocapitalize:"off",spellcheck:"false"}}),i=l("form",{className:"pair-form",on:{submit:n=>{n.preventDefault(),this.#A(e.value)}}},e,l("button",{className:"primary",text:"Connect",attrs:{type:"submit"}}));return l("section",{className:"empty-state access-problem",attrs:{role:"alert"}},l("h1",{text:t.title}),l("p",{text:t.body}),i,this.#F?l("p",{className:"pair-error",text:this.#F}):void 0)}#A(t){const e=Xt(t);if(!e){this.#F="That is not a Luvus pairing link or code.",this.#u();return}this.#W.ticket&&this.#C.clear(this.#P),this.#y=e,this.#F=void 0,this.start()}#O(){const t=this.#r,e=t?t.paired_devices+t.pending_pairings:1,i=l("select",{className:"device-limit",attrs:{"aria-label":"Maximum paired devices",...this.#s?{disabled:""}:{}},on:{change:d=>{this.#Q(Number(d.currentTarget.value))}}}),n=t?.limit_ceiling??8;for(let d=1;d<=n;d+=1)i.append(l("option",{text:String(d),attrs:{value:String(d),...t?.max_devices===d?{selected:""}:{},...d=(t?.max_devices??1)?"Device limit reached":"Pair another device","primary device-pair",()=>{this.#D()});r.disabled=this.#s||!t||e>=t.max_devices;const o=l("input",{className:"device-url-input",attrs:{type:"url",inputmode:"url",autocomplete:"url",placeholder:location.origin,value:t?.public_url??"","aria-label":"Public pairing address",...this.#s?{disabled:""}:{}},on:{keydown:d=>{d.key==="Enter"&&this.#X(d.currentTarget.value)}}}),a=y("Save address","ghost device-url-save",()=>{this.#X(o.value)});a.disabled=this.#s||!t;const c=y("Disconnect","device-disconnect",()=>{this.#J()});c.setAttribute("aria-label","Disconnect this browser"),c.prepend(Y("disconnect")),c.disabled=this.#s||!t;const h=l("section",{className:"device-panel",attrs:{role:"dialog","aria-modal":"true","aria-labelledby":"device-title","data-scroll-key":"device-panel"}},l("div",{className:"device-panel-head"},l("div",{},l("p",{className:"eyebrow",text:"BROWSER ACCESS"}),l("h2",{text:"Connected devices",attrs:{id:"device-title"}})),y("Close","device-close",()=>this.#I())),l("p",{className:"device-copy",text:t?`${t.paired_devices} authorized${t.pending_pairings?` · ${t.pending_pairings} link pending`:""}`:"Loading device access…"}),l("label",{className:"device-limit-row"},l("span",{text:"Maximum devices"}),i),l("div",{className:"device-url-setting"},l("label",{className:"device-url-label"},l("span",{text:"Pairing address"}),o),a),l("p",{className:"device-help",text:"Use your HTTPS tunnel address for phone links. Clear it to use this browser's address. This changes links only, not network exposure or origin permissions."}),l("p",{className:"device-help",text:"Pair each browser once. Access is remembered across tabs and browser restarts. "+(this.#e.ready?.expires_at?`This browser's access expires at ${new Date(this.#e.ready.expires_at*1e3).toLocaleString()}. `:"Access lasts until the bridge stops or this browser is revoked. ")+"Pairing links expire after five minutes."}),this.#f?this.#L(this.#f):r,l("footer",{className:"device-browser-access"},l("div",{className:"device-browser-info"},l("strong",{text:"This browser"}),l("span",{text:"Paired across all tabs"})),c));return l("div",{className:"device-overlay",on:{click:d=>{d.target===d.currentTarget&&this.#I()}}},h)}#L(t){return l("div",{className:"pairing-card"},l("strong",{text:"New device link"}),l("p",{text:"Scan with the phone camera, or use the link below."}),l("div",{className:"pairing-qr-wrap"},l("img",{className:"pairing-qr",attrs:{src:we(t),alt:"QR code containing the one-use Luvus device pairing link",width:"220",height:"220"}})),l("input",{className:"pairing-link",attrs:{value:t,readonly:"","aria-label":"One-use device pairing link"}}),l("div",{className:"pairing-actions"},y("Copy link","primary",()=>{this.#st(t)}),typeof navigator.share=="function"?y("Share","ghost",()=>{navigator.share({title:"Connect to Luvus",url:t}).catch(()=>{})}):void 0,y("Done","ghost",()=>{this.#a=void 0,this.#f=void 0,this.#u(),this.#H()})))}async#H(){if(this.#s||this.#t.state!=="ready")return;this.#s=!0;let t;try{this.#r=$(await this.#e.request("web.devices.status")),this.#u()}catch(e){t=e}finally{this.#s=!1,this.#n&&this.#u()}t&&this.#x(t)}async#J(){if(this.#s||!confirm("Disconnect this browser in every tab? You'll need a new QR/code to reconnect. Other browsers and running terminals are not affected."))return;this.#s=!0;const t=this.#P;try{await this.#e.request("web.devices.forget"),this.#C.clear(t),location.reload()}catch(e){this.#x(e)}finally{this.#s=!1,this.#u()}}async#Q(t){if(this.#s)return;this.#s=!0;let e;try{this.#r=$(await this.#e.request("web.devices.set_limit",{limit:t})),this.#u()}catch(i){e=i}finally{this.#s=!1,this.#n&&this.#u()}e&&this.#x(e)}async#X(t){if(this.#s)return;this.#s=!0;let e;try{const i=t.trim();this.#r=$(await this.#e.request("web.devices.set_public_url",{url:i||null})),this.#a&&(this.#f=this.#B(this.#a));const n=this.root.querySelector(".device-url-input");n&&ui(n,t),this.#u()}catch(i){e=i}finally{this.#s=!1,this.#n&&this.#u()}e?this.#x(e):this.#G("Pairing address updated")}async#D(){if(this.#s)return;this.#s=!0,this.#u();let t;try{const e=mi(await this.#e.request("web.devices.create_pairing"));this.#r=e.devices,this.#a=e.code,this.#f=e.url??this.#B(e.code),this.#u()}catch(e){t=e}finally{this.#s=!1,this.#n&&this.#u()}t&&this.#x(t)}#B(t){return`${this.#r?.public_url??location.origin}/#pair=${encodeURIComponent(t)}`}async#st(t){try{await navigator.clipboard.writeText(t),this.#G("Pairing link copied")}catch{document.querySelector(".pairing-link")?.select(),this.#G("Select and copy the pairing link")}}#I(){this.#n=!1,this.#u(),this.root.querySelector(this.#_.matches?'[data-view-key="mission-nav:devices"]':'[aria-label="Open navigation"]')?.focus({preventScroll:!0})}#nt(){this.#v=!1,this.#i?.closeNavigation(),this.#n=!0,this.#u(),this.#l.querySelector(".device-close")?.focus({preventScroll:!0}),this.#H()}#rt(){this.#c=!0,this.#h=void 0,this.#u(),this.#at()}#E(t=!1){this.#c=!1,this.#u(),t&&this.root.querySelector(".hero-session")?.focus({preventScroll:!0})}async#at(){if(this.#d||this.#t.state!=="ready")return;this.#d=!0,this.#u();let t;try{this.#h=gi(await this.#e.request("web.sessions.list"))}catch(e){this.#c=!1,t=e}finally{this.#d=!1,this.#u()}t&&this.#x(t)}async#ot(t){const e=this.#t.snapshot?.session;if(this.#d||t===e)return;this.#d=!0,this.#c=!1,this.#h=void 0,this.#u();let i;try{await this.#t.switchSession(t)}catch(n){i=n}finally{this.#d=!1,this.#u()}i?this.#x(i):this.#G(`Switched to ${t}`)}#Z(t){const e=this.#t.capabilities?.access?.mode==="control";return l("div",{className:"session-menu",attrs:{role:"dialog","aria-label":"Switch Luvus session"}},l("div",{className:"session-menu-head"},l("strong",{text:"Switch session"}),y("Close","session-menu-close",()=>this.#E(!0))),this.#d||!this.#h?l("p",{className:"session-menu-empty",text:"Loading sessions…"}):l("div",{className:"session-menu-list",attrs:{"data-scroll-key":"sessions"}},...this.#h.map(i=>{const n=i.name===t,r=n||!i.running&&!e;return l("button",{className:`session-option${n?" active":""}`,attrs:{type:"button",...r?{disabled:""}:{}},on:{click:()=>{this.#ot(i.name)}}},l("span",{text:i.name}),l("small",{text:n?"Current":i.running?"Running":e?"Start":"Control required"}))})))}#V(){return l("nav",{className:"mission-dock",attrs:{"aria-label":"Devices and connection"}},bi("Devices","devices",()=>this.#nt()),Se(this.#g.refresh),l("span",{className:`mission-nav-status ${this.#t.state}`,attrs:{role:"status","aria-label":`Connection ${this.#t.state}`,title:this.#t.state}},Y("status")))}#tt(){for(const t of this.root.querySelectorAll(".mission-nav-status"))t.className=`mission-nav-status ${this.#t.state}`,t.setAttribute("aria-label",`Connection ${this.#t.state}`),t.title=this.#t.state}#j(){this.#i||this.#_.matches||!this.#t.snapshot||this.#t.state==="expired"||(this.#v=!0,this.#c=!1,this.#u(),this.#k.focus({preventScroll:!0}))}#R(t=!1){this.#v&&(this.#v=!1,this.#u(),t&&!this.#_.matches&&this.#w.focus({preventScroll:!0}))}#lt(t){const e=this.#_.matches,i=e||this.#v,n=this.#p.resolve(t);this.#S.update(i?Lt(t,n?.pane):[]),this.#S.setFooter(this.#V()),this.#S.setCollapsed(this.#g.collapsed.workspaces),this.#b.update(i?this.#U():[],void 0,this.#m),this.#b.setCollapsed(this.#g.collapsed.panes);const{workspaces:r,panes:o}=this.#g.collapsed;this.#v?(this.#N.setAttribute("role","dialog"),this.#N.setAttribute("aria-modal","true")):(this.#N.removeAttribute("role"),this.#N.removeAttribute("aria-modal")),this.#w.setAttribute("aria-expanded",String(this.#v)),this.#z.inert=this.#v;const a=this.#K(t);a.inert=this.#v;const c=l("div",{className:"web-content-frame"},this.#z,a);return!e&&this.#b.root.parentElement!==this.#N&&this.#N.append(this.#b.root),l("div",{className:`dashboard-layout${r?" workspaces-collapsed":""}${o?" panes-collapsed":""}${this.#v?" pane-navigation-open":""}`},this.#N,c,e?this.#b.root:void 0)}#K(t){const e=t.workspaces.flatMap(a=>a.tabs.flatMap(c=>c.panes)).filter(a=>a.kind==="terminal"&&a.is_agent===!0),i=e.length,n=e.filter(a=>a.agent_status==="working").length,r=t.workspaces.reduce((a,c)=>a+c.tabs.length,0),o=t.workspaces.reduce((a,c)=>a+c.tabs.reduce((h,u)=>h+u.panes.length,0),0);return l("div",{className:"dashboard",attrs:{"data-scroll-key":"dashboard-main"}},l("section",{className:"mission-hero",attrs:{id:"mission-overview"}},l("div",{className:"hero-layout"},l("div",{className:"hero-stat-column stats-left"},O(String(t.workspaces.length).padStart(2,"0"),"Workspaces"),O(String(r).padStart(2,"0"),"Tabs")),l("div",{className:"hero-center"},l("h1",{text:"Live stats"}),l("div",{className:"mission-core",attrs:{"aria-label":"Luvus network online"}},l("div",{className:"orbit orbit-outer"}),l("div",{className:"orbit orbit-inner"}),l("div",{className:"core-mark"},l("img",{attrs:{src:"/mark.svg",alt:""}})),l("div",{className:"core-label"},l("strong",{text:"SYSTEM ONLINE"}),l("small",{text:`${n} executing`}))),l("div",{className:"hero-session-wrap"},l("button",{className:"hero-session",attrs:{type:"button","aria-haspopup":"dialog","aria-expanded":String(this.#c)},on:{click:()=>this.#c?this.#E():this.#rt()}},l("span",{text:"Session"}),l("strong",{text:t.session})),this.#c?this.#Z(t.session):void 0)),l("div",{className:"hero-stat-column stats-right"},O(String(o).padStart(2,"0"),"Live panes"),O(String(i).padStart(2,"0"),"Agents")))))}#et(t,e){const i=this.#t.allowedMethods,n=i.has("terminal.backend.control");if(!n&&!i.has("terminal.backend.observe"))return;this.#v=!1,this.#p.select(t,e),this.#o.opened(t,e),this.#i?.destroy();const r=this.#t.capabilities?.terminal?.features?.includes("stream_cursor")??!1,o=n&&Ne(this.#t.capabilities?.terminal?.capabilities),a=Ce(this.#t.capabilities?.terminal?.capabilities,n),c=new li(this.#e,t,e,n,o,r,a,this.#t.capabilities?.terminal?.features?.includes("modified_keys")??!1,()=>this.#U(),h=>{const u=this.#t.snapshot;u&&this.#et(u,h)},()=>{c.destroy(),this.#i=void 0,this.#u()},{showShells:this.#m,onChange:h=>{this.#m=h}},this.#g);this.#i=c,this.root.replaceChildren(c.root,this.#l),c.start().catch(h=>this.#x(h))}#U(){const t=this.#t.snapshot;return t?Oe(this.#o.options(t),this.#p.resolve(t)?.workspace):[]}#x(t){const e=t instanceof p||t instanceof Error?t.message:"Unexpected connection error",i=l("div",{className:"toast",text:e});this.root.append(i),setTimeout(()=>i.remove(),5e3)}#G(t){const e=l("div",{className:"toast success",text:t});this.root.append(e),setTimeout(()=>e.remove(),3e3)}}function pi(){const t=new URLSearchParams(location.hash.slice(1)).get("pair")||void 0;return t&&history.replaceState(null,"",`${location.pathname}${location.search}`),t}function $(s){const t=s;if(!t||t.type!=="browser_device_status"||!Number.isSafeInteger(t.paired_devices)||!Number.isSafeInteger(t.pending_pairings)||!Number.isSafeInteger(t.max_devices)||t.public_url!==null&&typeof t.public_url!="string"||t.limit_ceiling!==void 0&&!Number.isSafeInteger(t.limit_ceiling))throw new p("Invalid browser device status","invalid_response");return t}function mi(s){const t=s;if(!t||t.type!=="browser_device_pairing"||typeof t.code!="string"||!Number.isSafeInteger(t.expires_at)||!t.devices||t.url!==void 0&&typeof t.url!="string")throw new p("Invalid browser pairing response","invalid_response");return{...t,devices:$(t.devices)}}function gi(s){const t=s;if(t?.type!=="browser_session_list"||!Array.isArray(t.sessions)||t.sessions.length>256)throw new p("Invalid browser session list","invalid_response");return t.sessions.map(e=>{const i=e;if(!i||typeof i.name!="string"||!i.name||typeof i.default!="boolean"||typeof i.running!="boolean")throw new p("Invalid browser session entry","invalid_response");return i})}function bi(s,t,e,i=!1,n=!1){return l("button",{className:`mission-nav-button${i?" active":""}`,attrs:{type:"button","aria-label":s,title:s,"data-view-key":`mission-nav:${t}`,...n?{disabled:""}:{}},on:{click:e}},Y(t),l("span",{className:"mission-nav-label",text:s}))}function O(s,t){return l("div",{className:"mission-stat"},l("strong",{text:s}),l("div",{},l("span",{text:t})))}function Y(s){const t={overview:["M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z"],workspaces:["M3 6.5h7l2 2h9v11H3z","M3 6.5V4h7l2 2"],devices:["M8 2h8a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z","M10 18h4"],status:["M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z","m8 12 2.5 2.5L16 9"],arrow:["M5 12h14m-5-5 5 5-5 5"],disconnect:["M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4","m16 17 5-5-5-5","M21 12H9"]},e=document.createElementNS("http://www.w3.org/2000/svg","svg");e.setAttribute("class","mission-icon"),e.setAttribute("viewBox","0 0 24 24"),e.setAttribute("aria-hidden","true");for(const i of t[s]){const n=document.createElementNS("http://www.w3.org/2000/svg","path");n.setAttribute("d",i),n.setAttribute("fill",s==="overview"?"currentColor":"none"),n.setAttribute("stroke","currentColor"),n.setAttribute("stroke-width","1.5"),n.setAttribute("stroke-linecap","round"),n.setAttribute("stroke-linejoin","round"),e.append(n)}return e}const Ft=document.querySelector("#app");if(!Ft)throw new Error("Missing app root");const vi=new fi(Ft);vi.start();wi().catch(()=>{});async function wi(){if(!("serviceWorker"in navigator))return;const s=await navigator.serviceWorker.getRegistrations();await Promise.all(s.map(async t=>{try{await t.update()}catch{await t.unregister()}}))} diff --git a/web/packages/app/src/app.ts b/web/packages/app/src/app.ts index 995d7e084..da6427c4b 100644 --- a/web/packages/app/src/app.ts +++ b/web/packages/app/src/app.ts @@ -113,13 +113,26 @@ export class WebApp { document.addEventListener("keydown", (event) => { const dialog = this.#devicePanelOpen ? this.#overlays.querySelector(".device-panel") : !this.#terminal && this.#dashboardNavigationOpen ? this.#dashboardNavigation : undefined; - if (!dialog) return; + if (!dialog) { + if (event.key === "Escape" && this.#sessionPanelOpen) { + event.preventDefault(); + this.#closeSessionPanel(true); + } + return; + } if (event.key === "Escape") { event.preventDefault(); if (this.#devicePanelOpen) this.#closeDevicePanel(); else this.#closeDashboardNavigation(true); } else trapNavigationFocus(event, dialog); }); + document.addEventListener("click", (event) => { + if (!this.#sessionPanelOpen) return; + // Opening the menu can replace its trigger during this same click. + // Use the original event path, not containment in the freshly drawn DOM. + if (event.composedPath().some((node) => node instanceof HTMLElement && node.classList.contains("hero-session-wrap"))) return; + this.#closeSessionPanel(); + }); // Hold live redraws while a pointer is pressed, so the pressed element // is still there when it is released and the click is delivered. root.addEventListener("pointerdown", (event) => this.#renders.hold(event.pointerId), true); @@ -351,9 +364,11 @@ export class WebApp { }); const savePublicUrl = button("Save address", "ghost device-url-save", () => void this.#setPublicUrl(publicUrl.value)); savePublicUrl.disabled = this.#deviceLoading || !status; - const disconnectButton = button("Disconnect this browser", "ghost device-disconnect", () => void this.#disconnectBrowser()); + const disconnectButton = button("Disconnect", "device-disconnect", () => void this.#disconnectBrowser()); + disconnectButton.setAttribute("aria-label", "Disconnect this browser"); + disconnectButton.prepend(missionIcon("disconnect")); disconnectButton.disabled = this.#deviceLoading || !status; - const panel = element("section", { className: "device-panel", attrs: { role: "dialog", "aria-modal": "true", "aria-labelledby": "device-title" } }, + const panel = element("section", { className: "device-panel", attrs: { role: "dialog", "aria-modal": "true", "aria-labelledby": "device-title", "data-scroll-key": "device-panel" } }, element("div", { className: "device-panel-head" }, element("div", {}, element("p", { className: "eyebrow", text: "BROWSER ACCESS" }), @@ -382,7 +397,13 @@ export class WebApp { : "Access lasts until the bridge stops or this browser is revoked. ") + "Pairing links expire after five minutes." }), this.#pairingUrl ? this.#pairingCard(this.#pairingUrl) : pairButton, - disconnectButton, + element("footer", { className: "device-browser-access" }, + element("div", { className: "device-browser-info" }, + element("strong", { text: "This browser" }), + element("span", { text: "Paired across all tabs" }), + ), + disconnectButton, + ), ); const overlay = element("div", { className: "device-overlay", @@ -550,9 +571,10 @@ export class WebApp { void this.#refreshSessions(); } - #closeSessionPanel(): void { + #closeSessionPanel(restoreFocus = false): void { this.#sessionPanelOpen = false; this.#render(); + if (restoreFocus) this.root.querySelector(".hero-session")?.focus({ preventScroll: true }); } async #refreshSessions(): Promise { @@ -597,7 +619,7 @@ export class WebApp { return element("div", { className: "session-menu", attrs: { role: "dialog", "aria-label": "Switch Luvus session" } }, element("div", { className: "session-menu-head" }, element("strong", { text: "Switch session" }), - button("Close", "session-menu-close", () => this.#closeSessionPanel()), + button("Close", "session-menu-close", () => this.#closeSessionPanel(true)), ), this.#sessionLoading || !this.#sessions ? element("p", { className: "session-menu-empty", text: "Loading sessions…" }) @@ -750,6 +772,7 @@ export class WebApp { canUploadFiles, streamCursor, viewportSizing, + this.#session.capabilities?.terminal?.features?.includes("modified_keys") ?? false, () => this.#terminalPaneOptions(), (selectedPane) => { const currentSnapshot = this.#session.snapshot; @@ -831,7 +854,7 @@ function asBrowserSessions(value: unknown): BrowserSession[] { }); } -type MissionIconName = "overview" | "workspaces" | "devices" | "status" | "arrow"; +type MissionIconName = "overview" | "workspaces" | "devices" | "status" | "arrow" | "disconnect"; type MissionNavIconName = MissionIconName | "luvus"; function missionNavButton(label: string, icon: MissionNavIconName, onClick: () => void, active = false, disabled = false): HTMLButtonElement { @@ -859,6 +882,7 @@ function missionIcon(name: MissionIconName): SVGSVGElement { devices: ["M8 2h8a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Z", "M10 18h4"], status: ["M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z", "m8 12 2.5 2.5L16 9"], arrow: ["M5 12h14m-5-5 5 5-5 5"], + disconnect: ["M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4", "m16 17 5-5-5-5", "M21 12H9"], }; const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("class", "mission-icon"); diff --git a/web/packages/app/src/native-input.ts b/web/packages/app/src/native-input.ts index a8ef05853..4fdf09aea 100644 --- a/web/packages/app/src/native-input.ts +++ b/web/packages/app/src/native-input.ts @@ -1,5 +1,5 @@ import { element } from "./dom.js"; -import { deletionInputKey, logicalKey } from "./native-keymap.js"; +import { deletionInputKey, logicalKey, modifiedLogicalKey, shiftedText, type TerminalModifiers } from "./native-keymap.js"; export type TerminalAction = | "type_literal" @@ -16,6 +16,10 @@ type Dispatch = (action: TerminalAction, params: Record) => Pro const MAX_INPUT_BYTES = 256 * 1024; const encoder = new TextEncoder(); +// Native phone keyboards stop deleting when their editable host is empty. +// Keep one invisible character on each side of the caret; never send these +// browser-only guards to the terminal. +const INPUT_GUARD = "\u200b"; export class NativeTerminalInput { readonly element = element("textarea", { @@ -37,18 +41,29 @@ export class NativeTerminalInput { #destroyed = false; #flushFrame: number | undefined; #ignoreCommittedInput = ""; + #handledBeforeInput: string | undefined; + #modifiers: TerminalModifiers = { ctrl: false, shift: false }; constructor( private readonly dispatch: Dispatch, private readonly onError: (message: string) => void, private readonly onFiles: (files: File[]) => void, + private readonly onModifiersChanged: (modifiers: Readonly) => void = () => {}, ) { + this.#resetInput(); this.element.addEventListener("keydown", (event) => this.#onKeyDown(event)); this.element.addEventListener("beforeinput", (event) => this.#onBeforeInput(event)); - this.element.addEventListener("input", () => this.#onInput()); - this.element.addEventListener("compositionstart", () => { this.#composing = true; }); + this.element.addEventListener("input", (event) => this.#onInput(event as InputEvent)); + this.element.addEventListener("compositionstart", () => { + this.#composing = true; + this.element.setAttribute("data-empty", "false"); + }); this.element.addEventListener("compositionend", (event) => this.#onCompositionEnd(event)); this.element.addEventListener("paste", (event) => this.#onPaste(event)); + this.element.addEventListener("blur", () => { + this.clearModifiers(); + this.#flushText(); + }); } focus(): void { @@ -60,12 +75,37 @@ export class NativeTerminalInput { } sendKey(key: string): void { + const modifiers = this.#modifiers; + this.clearModifiers(); + const chord = modifiers.ctrl || modifiers.shift ? modifiedLogicalKey(key, modifiers) : key; + if (!chord) { + this.onError("This key does not support the selected terminal modifiers."); + return; + } + this.#sendLogicalKey(chord); + } + + toggleModifier(modifier: keyof TerminalModifiers): void { + if (this.#destroyed || this.#composing) return; + this.focus(); + this.#modifiers = { ...this.#modifiers, [modifier]: !this.#modifiers[modifier] }; + this.onModifiersChanged(this.#modifiers); + } + + clearModifiers(): void { + if (!this.#modifiers.ctrl && !this.#modifiers.shift) return; + this.#modifiers = { ctrl: false, shift: false }; + this.onModifiersChanged(this.#modifiers); + } + + #sendLogicalKey(key: string): void { this.#flushText(); this.#enqueue("send_key", { key }); this.focus(); } destroy(): void { + this.clearModifiers(); this.#destroyed = true; this.#buffer = ""; if (this.#flushFrame !== undefined) cancelAnimationFrame(this.#flushFrame); @@ -75,6 +115,35 @@ export class NativeTerminalInput { #onKeyDown(event: KeyboardEvent): void { if (event.isComposing || this.#composing) return; + this.#handledBeforeInput = undefined; + // Let touch keyboards perform their native edit and emit beforeinput/input. + // Cancelling Backspace here can stop the OS's held-key repetition. + if ((event.key === "Backspace" || event.key === "Delete") + && !event.ctrlKey && !event.altKey && !event.metaKey + && window.matchMedia("(pointer: coarse)").matches) return; + // Actual browser copy/paste shortcuts remain native. A deliberately armed + // toolbar Ctrl is distinct from those physical shortcuts. + const copy = event.ctrlKey && event.key.toLowerCase() === "c" + && (event.shiftKey || !!window.getSelection()?.toString()); + if ((event.metaKey && event.key !== "Backspace" && event.key !== "Delete") + || event.ctrlKey && event.key.toLowerCase() === "v" || copy) { + this.clearModifiers(); + return; + } + if (event.altKey) this.clearModifiers(); // AltGr and Option text remain browser-native. + if ((this.#modifiers.ctrl || this.#modifiers.shift) && !event.altKey && !event.metaKey) { + const base = logicalKey({ ...event, key: event.key, ctrlKey: false, altKey: false, metaKey: false, shiftKey: false }) + ?? (event.key === " " ? "space" : event.key); + const key = modifiedLogicalKey(base, { ctrl: this.#modifiers.ctrl || event.ctrlKey, shift: this.#modifiers.shift || event.shiftKey }); + if (key) { + event.preventDefault(); + this.clearModifiers(); + this.#sendLogicalKey(key); + } + // Printable Shift input arrives through beforeinput; do not synthesize + // a second character or interfere with the phone keyboard's own edits. + return; + } const key = logicalKey(event); if (!key) return; if (key === "ctrl-c" && window.getSelection()?.toString()) return; @@ -84,47 +153,93 @@ export class NativeTerminalInput { #onBeforeInput(event: InputEvent): void { if (event.isComposing || this.#composing) return; + this.#handledBeforeInput = undefined; if (event.inputType === "insertLineBreak" || event.inputType === "insertParagraph") { + if (!event.cancelable) return; event.preventDefault(); + this.#resetInput(); this.sendKey("enter"); return; } const deletionKey = deletionInputKey(event.inputType); if (deletionKey) { - event.preventDefault(); + // The native edit must complete so mobile keyboards keep repeating. + // Its following input event only restores the guards, not another key. + this.#handledBeforeInput = event.inputType; this.sendKey(deletionKey); return; } - if (event.data && event.inputType.startsWith("insert")) { + if (event.cancelable && event.data && event.inputType.startsWith("insert")) { event.preventDefault(); - this.element.value = ""; - this.#queueText(event.data); + this.#resetInput(); + this.#commitText(event.data, event.inputType === "insertFromPaste"); } } - #onInput(): void { - if (this.#composing || !this.element.value) return; - const text = this.element.value; - this.element.value = ""; + #onInput(event: InputEvent): void { + if (event.isComposing || this.#composing) return; + const handled = this.#handledBeforeInput !== undefined && this.#handledBeforeInput === event.inputType; + this.#handledBeforeInput = undefined; + const text = event.data ?? this.#proxyText(); + this.#resetInput(); + if (handled) return; + const deletionKey = deletionInputKey(event.inputType); + if (deletionKey) this.sendKey(deletionKey); + else if (event.inputType === "insertLineBreak" || event.inputType === "insertParagraph") this.sendKey("enter"); + else this.#commitText(text, event.inputType === "insertFromPaste"); + } + + #resetInput(): void { + this.#handledBeforeInput = undefined; + this.element.value = INPUT_GUARD + INPUT_GUARD; + this.element.setSelectionRange(1, 1); + this.element.setAttribute("data-empty", "true"); + } + + #proxyText(): string { + let text = this.element.value; + if (text.startsWith(INPUT_GUARD)) text = text.slice(1); + if (text.endsWith(INPUT_GUARD)) text = text.slice(0, -1); + return text; + } + + #commitText(text: string, paste = false): void { + if (!text) return; if (text === this.#ignoreCommittedInput) { this.#ignoreCommittedInput = ""; return; } this.#ignoreCommittedInput = ""; - this.#queueText(text); + if (paste) this.#pasteText(text); + else this.#forwardText(text); + } + + #forwardText(text: string, composition = false): void { + const modifiers = this.#modifiers; + this.clearModifiers(); + if (modifiers.ctrl) { + const key = /^[a-z ]$/i.test(text) ? modifiedLogicalKey(text === " " ? "space" : text, modifiers) : undefined; + if (key) this.#sendLogicalKey(key); + else if (composition) this.#queueText(text); + else this.onError("Ctrl needs one letter or Space. Paste and IME input are sent unchanged."); + } else this.#queueText(modifiers.shift ? shiftedText(text) : text); } #onCompositionEnd(event: CompositionEvent): void { this.#composing = false; - const text = event.data || this.element.value; - this.element.value = ""; - if (!text) return; + const text = event.data || this.#proxyText(); + this.#resetInput(); + if (!text) { + this.clearModifiers(); + return; + } this.#ignoreCommittedInput = text; queueMicrotask(() => { this.#ignoreCommittedInput = ""; }); - this.#queueText(text); + this.#forwardText(text, true); } #onPaste(event: ClipboardEvent): void { + this.clearModifiers(); const transfer = event.clipboardData; const files = Array.from(transfer?.files ?? []); if (!files.length) { @@ -135,7 +250,7 @@ export class NativeTerminalInput { } if (files.length) { event.preventDefault(); - this.element.value = ""; + this.#resetInput(); this.#flushText(); this.onFiles(files); return; @@ -143,7 +258,12 @@ export class NativeTerminalInput { const text = event.clipboardData?.getData("text/plain") ?? ""; if (!text) return; event.preventDefault(); - this.element.value = ""; + this.#resetInput(); + this.#pasteText(text); + } + + #pasteText(text: string): void { + this.clearModifiers(); this.#flushText(); if (encoder.encode(text).byteLength > MAX_INPUT_BYTES) { this.onError("Paste is larger than the 256 KiB terminal input limit."); diff --git a/web/packages/app/src/native-keymap.ts b/web/packages/app/src/native-keymap.ts index 813c396b4..2a567f006 100644 --- a/web/packages/app/src/native-keymap.ts +++ b/web/packages/app/src/native-keymap.ts @@ -8,6 +8,36 @@ export interface BrowserKey { const CONTROL_LETTERS = new Set(["a", "b", "c", "d", "e", "f", "g", "k", "l", "n", "o", "p", "r", "t", "u", "w", "y"]); +export interface TerminalModifiers { + ctrl: boolean; + shift: boolean; +} + +/** One-shot toolbar modifiers use logical keys, never browser escape bytes. */ +export function modifiedLogicalKey(key: string, { ctrl, shift }: TerminalModifiers): string | undefined { + if (/^ctrl-(shift-)?([a-z]|space|enter|tab|up|down|left|right|home|end|pageup|pagedown)$/.test(key) || key === "alt-d") return key; + if (key === "backspace") return ctrl ? "ctrl-w" : key; + if (key === "delete") return ctrl ? "alt-d" : key; + if (key === "escape") return key; + if (key === "tab" || key === "backtab") { + return ctrl ? `ctrl-${shift || key === "backtab" ? "shift-" : ""}tab` : shift ? "backtab" : key; + } + if (["enter", "up", "down", "left", "right", "home", "end", "pageup", "pagedown"].includes(key)) { + return `${ctrl ? "ctrl-" : ""}${shift ? "shift-" : ""}${key}`; + } + if (ctrl && (/^[a-z]$/i.test(key) || key === "space")) return `ctrl-${shift ? "shift-" : ""}${key.toLowerCase()}`; + return undefined; +} + +/** Shift only the next ASCII character; leave IME, Unicode and pasted text native. */ +export function shiftedText(text: string): string { + const first = text[0] ?? ""; + const plain = "`1234567890-=[]\\;',./"; + const shifted = '~!@#$%^&*()_+{}|:"<>?'; + const index = plain.indexOf(first); + return (index >= 0 ? shifted[index] : /^[a-z]$/.test(first) ? first.toUpperCase() : first) + text.slice(1); +} + /** Translate browser keys into the bounded terminal-control vocabulary. */ export function logicalKey(event: BrowserKey): string | undefined { if (event.key === "Backspace") { diff --git a/web/packages/app/src/styles.css b/web/packages/app/src/styles.css index ac38b79f7..2b7398629 100644 --- a/web/packages/app/src/styles.css +++ b/web/packages/app/src/styles.css @@ -207,7 +207,7 @@ button:disabled { cursor: not-allowed; opacity: .45; } .toast.success { color: #e6ffdf; background: #243b27; border-color: #9ed68a66; } .device-overlay { position: fixed; z-index: 25; inset: 0; display: grid; place-items: end center; padding: 1rem; background: #090a10aa; backdrop-filter: blur(10px); } -.device-panel { width: min(32rem, 100%); padding: 1.2rem; background: var(--panel-raised); border: 1px solid var(--line); border-radius: 1.25rem; box-shadow: 0 1.5rem 5rem #000a; } +.device-panel { width: min(32rem, 100%); max-height: calc(100dvh - 2rem); overflow-y: auto; overscroll-behavior: contain; padding: 1.2rem; background: var(--panel-raised); border: 1px solid var(--line); border-radius: 1.25rem; box-shadow: 0 1.5rem 5rem #000a; } .device-panel-head { display: flex; align-items: start; justify-content: space-between; gap: 1rem; } .device-panel h2 { margin: .25rem 0 0; font-size: 1.25rem; } .device-close { padding: .35rem .45rem; color: var(--muted); background: transparent; border: 0; cursor: pointer; } @@ -223,6 +223,14 @@ button:disabled { cursor: not-allowed; opacity: .45; } .device-help { margin: .65rem 0 0; color: var(--muted); font-size: .78rem; line-height: 1.45; } .device-help + .device-help { margin-bottom: 1rem; } .device-pair { width: 100%; } +.device-browser-access { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-top: 1.1rem; padding-top: 1rem; border-top: 1px solid var(--line); } +.device-browser-info { display: grid; gap: .25rem; min-width: 0; } +.device-browser-info strong { color: #e2dfeb; font-size: .85rem; font-weight: 600; } +.device-browser-info span { color: var(--muted); font-size: .75rem; } +.device-disconnect { display: inline-flex; flex: 0 0 auto; align-items: center; justify-content: center; gap: .45rem; min-height: 2.75rem; padding: .55rem .8rem; color: var(--red); background: #ed8f9e08; border: 1px solid #ed8f9e33; border-radius: .65rem; font-size: .82rem; font-weight: 600; cursor: pointer; } +.device-disconnect .mission-icon { width: 1rem; height: 1rem; } +.device-disconnect:hover:not(:disabled) { background: #ed8f9e14; border-color: #ed8f9e66; } +.device-disconnect:focus-visible { outline: 2px solid var(--red); outline-offset: 3px; } .pairing-card { padding: .9rem; background: #11121988; border: 1px solid #ead77855; border-radius: .8rem; } .pairing-card strong, .pairing-card p { display: block; } .pairing-card p { margin: .35rem 0 .7rem; color: var(--muted); font-size: .82rem; } @@ -391,6 +399,7 @@ button:disabled { cursor: not-allowed; opacity: .45; } .terminal-tool { display: grid; flex: 0 0 auto; place-items: center; min-width: 2.1rem; height: 2.1rem; padding: 0 .5rem; color: #c2c0cc; background: transparent; border: 0; border-radius: 999px; font: .68rem ui-monospace, monospace; cursor: pointer; } .terminal-tool:hover, .terminal-tool:focus-visible { color: #f4f1ff; background: #ffffff0d; outline: none; } .terminal-tool:active { color: var(--accent-ink); background: var(--accent); } +.terminal-tool[aria-pressed="true"] { color: var(--accent-ink); background: var(--accent); } .terminal-tool:disabled { cursor: not-allowed; opacity: .38; } .keyboard-toggle { color: var(--accent); font-family: inherit; font-size: .9rem; } .attach-file { color: var(--accent); font-family: inherit; font-size: 1.2rem; font-weight: 600; } @@ -404,6 +413,26 @@ button:disabled { cursor: not-allowed; opacity: .45; } position: fixed; right: 0; bottom: 0; width: 1px; height: 1px; padding: 0; overflow: hidden; opacity: 0; border: 0; outline: 0; resize: none; pointer-events: none; } +.terminal-input-surface { flex: 0 0 0; width: 0; } +.terminal-input-placeholder { display: none; } + +@media (pointer: coarse) { + .terminal-output { padding-bottom: calc(var(--terminal-circle-size) + 1.5rem); } + .controls-expanded .terminal-output { padding-bottom: calc(3 * var(--terminal-circle-size) + 2rem); } + .terminal-controls-wrap { flex-wrap: wrap; gap: .5rem; padding-left: max(1rem, env(safe-area-inset-left)); } + .terminal-input-surface { position: relative; flex: 1 1 0; min-width: 0; pointer-events: auto; } + .controls-expanded .terminal-input-surface { flex-basis: 100%; } + .controls-expanded .terminal-controls { width: 100%; align-items: flex-start; gap: .25rem; border-radius: 1rem; } + .controls-expanded .terminal-tools { flex: 1 1 auto; display: grid; grid-template-columns: repeat(auto-fit, minmax(44px, 1fr)); max-width: none; overflow: visible; } + .terminal-tools .terminal-tool { min-width: 44px; min-height: 44px; padding-inline: .25rem; touch-action: manipulation; } + .terminal-input-proxy { position: static; display: block; width: 100%; height: var(--terminal-circle-size); padding: .85rem .8rem; opacity: 1; color: #d9d7e7; background: #1b1c24e8; border: 1px solid var(--rally-border); border-radius: .85rem; font: 16px/1.3 ui-monospace, monospace; caret-color: var(--accent); pointer-events: auto; -webkit-user-select: text; user-select: text; -webkit-touch-callout: default; } + .terminal-input-proxy:focus { border-color: var(--rally-border-focus); } + .terminal-input-proxy[data-empty="true"] + .terminal-input-placeholder { display: block; position: absolute; top: 50%; left: .8rem; right: .8rem; overflow: hidden; color: var(--muted); font-size: .8rem; text-overflow: ellipsis; white-space: nowrap; pointer-events: none; transform: translateY(-50%); } +} + +@media (pointer: coarse) and (max-width: 360px) { + .controls-expanded .terminal-output { padding-bottom: calc(4 * var(--terminal-circle-size) + 2rem); } +} @media (min-width: 1024px) { .terminal-screen, .dashboard-layout { @@ -506,7 +535,7 @@ button:disabled { cursor: not-allowed; opacity: .45; } .pane-grid { grid-template-columns: repeat(auto-fit, minmax(6.5rem, 1fr)); } .terminal-screen::before { display: none; } .terminal-header { width: calc(100% - 1.8rem); gap: .45rem; } - .terminal-output { padding: 4.8rem .8rem 1.25rem; font-size: .9rem; line-height: 1.6; } + .terminal-output { padding-top: 4.8rem; padding-inline: .8rem; font-size: .9rem; line-height: 1.6; } .terminal-controls-wrap { bottom: max(.55rem, env(safe-area-inset-bottom)); padding-right: max(.9rem, env(safe-area-inset-right)); } .terminal-tools { gap: .05rem; } .terminal-tool { min-width: 2.35rem; height: 2.35rem; padding-inline: .5rem; font-size: .82rem; } diff --git a/web/packages/app/src/terminal-view.ts b/web/packages/app/src/terminal-view.ts index aeb41255b..67d67804f 100644 --- a/web/packages/app/src/terminal-view.ts +++ b/web/packages/app/src/terminal-view.ts @@ -38,6 +38,8 @@ export class TerminalView { #pendingPaint: { text: string; cursorOffset: number | undefined; cursorPadding: number } | undefined; #renderedPaint: { text: string; cursorOffset: number | undefined; cursorPadding: number } | undefined; #input: NativeTerminalInput | undefined; + #modifierButtons: Partial> = {}; + #inputPlaceholder = element("span", { className: "terminal-input-placeholder", text: "Type or hold to paste", attrs: { "aria-hidden": "true" } }); #inputHint = element("span", { className: "terminal-input-hint", text: "Tap terminal to type" }); #attach: HTMLButtonElement | undefined; #uploadTail: Promise = Promise.resolve(); @@ -83,6 +85,7 @@ export class TerminalView { private readonly canUploadFiles: boolean, private readonly streamCursor: boolean, private readonly viewportSizing: boolean, + private readonly modifiedKeys: boolean, private readonly paneOptions: () => TerminalPaneOption[], private readonly onSelectPane: (pane: PaneSnapshot) => void, onBack: () => void, @@ -99,6 +102,11 @@ export class TerminalView { (files) => { if (this.canUploadFiles) this.#queueFiles(files); }, + (modifiers) => { + for (const modifier of ["ctrl", "shift"] as const) this.#modifierButtons[modifier]?.setAttribute("aria-pressed", String(modifiers[modifier])); + const active = [modifiers.ctrl ? "Ctrl" : "", modifiers.shift ? "Shift" : ""].filter(Boolean).join(" + "); + this.#inputPlaceholder.textContent = active ? `${active} · next key` : "Type or hold to paste"; + }, ); this.#input.element.addEventListener("focus", () => this.#scheduleViewport(true)); this.#input.element.addEventListener("focus", () => { @@ -161,10 +169,24 @@ export class TerminalView { keyboard.disabled = !control; keyboard.setAttribute("aria-label", "Show or hide keyboard"); keyboard.title = "Keyboard"; + keyboard.addEventListener("pointerdown", (event) => event.preventDefault()); + + const modifiers = (["ctrl", "shift"] as const).map((modifier) => { + const label = modifier === "ctrl" ? "Ctrl" : "Shift"; + const toggle = button(label, "terminal-tool terminal-modifier", () => this.#input?.toggleModifier(modifier)); + toggle.disabled = !control || !modifiedKeys; + toggle.setAttribute("aria-label", modifier === "ctrl" ? "Control modifier" : "Shift modifier"); + toggle.setAttribute("aria-pressed", "false"); + toggle.title = modifiedKeys ? `${label} — next key` : "Update the Luvus server to use terminal modifiers"; + toggle.addEventListener("pointerdown", (event) => event.preventDefault()); + this.#modifierButtons[modifier] = toggle; + return toggle; + }); const keySpecs = [ ["escape", "Esc"], ["tab", "Tab"], + ["enter", "↵"], ["up", "↑"], ["down", "↓"], ["left", "←"], @@ -178,12 +200,19 @@ export class TerminalView { controlButton.addEventListener("pointerdown", (event) => event.preventDefault()); return controlButton; }); - const tools = element("div", { className: "terminal-tools" }, attach, keyboard, ...keys); + const tools = element("div", { className: "terminal-tools" }, attach, keyboard, ...modifiers, ...keys); + tools.inert = true; + tools.setAttribute("aria-hidden", "true"); const controlsToggle = button("", "terminal-controls-toggle", () => { const expanded = this.root.classList.toggle("controls-expanded"); + tools.inert = !expanded; + tools.setAttribute("aria-hidden", String(!expanded)); + if (!expanded) this.#input?.clearModifiers(); controlsToggle.setAttribute("aria-expanded", String(expanded)); controlsToggle.setAttribute("aria-label", expanded ? "Hide terminal controls" : "Show terminal controls"); controlsToggle.title = expanded ? "Hide terminal controls" : "Show terminal controls"; + if (this.#followTail) this.#scrollToLatest(); + this.#scheduleViewport(); }); controlsToggle.setAttribute("aria-expanded", "false"); controlsToggle.setAttribute("aria-label", "Show terminal controls"); @@ -224,6 +253,10 @@ export class TerminalView { this.#main = element("div", { className: "terminal-main" }, this.#output, element("div", { className: "terminal-controls-wrap" }, + this.#input ? element("div", { className: "terminal-input-surface" }, + this.#input.element, + this.#inputPlaceholder, + ) : undefined, element("div", { className: "terminal-controls" }, this.#inputHint, tools, @@ -236,12 +269,14 @@ export class TerminalView { this.#navigation, this.#contentFrame, fileInput, - ...(this.#input ? [this.#input.element] : []), ); this.#output.append(this.#cellProbe); this.#placePaneSidebar(); if (this.viewportSizing) { - this.#outputResize = new ResizeObserver(() => this.#scheduleViewport()); + this.#outputResize = new ResizeObserver(() => { + if (this.#followTail) this.#scrollToLatest(); + this.#scheduleViewport(); + }); this.#outputResize.observe(this.#output); } this.#output.addEventListener("scroll", () => { diff --git a/web/packages/app/test/native-input.test.mjs b/web/packages/app/test/native-input.test.mjs new file mode 100644 index 000000000..18a39bb7b --- /dev/null +++ b/web/packages/app/test/native-input.test.mjs @@ -0,0 +1,252 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { NativeTerminalInput } from "../dist/test/native-input.js"; + +const guard = "\u200b"; +const event = (type, fields = {}, cancelable = true) => Object.assign(new Event(type, { cancelable }), fields); + +async function withInput(run, coarse = true) { + const names = ["document", "window", "requestAnimationFrame", "cancelAnimationFrame"]; + const saved = new Map(names.map((name) => [name, globalThis[name]])); + const frames = new Map(), actions = [], errors = [], files = [], modifiers = []; + let nextFrame = 0; + class Textarea extends EventTarget { + value = ""; + selectionStart = 0; + selectionEnd = 0; + attrs = new Map(); + setAttribute(name, value) { this.attrs.set(name, value); } + setSelectionRange(start, end) { this.selectionStart = start; this.selectionEnd = end; } + focus() { document.activeElement = this; } + blur() { document.activeElement = null; this.dispatchEvent(new Event("blur")); } + remove() {} + } + globalThis.document = { createElement: () => new Textarea(), activeElement: null }; + globalThis.window = { matchMedia: () => ({ matches: coarse }), getSelection: () => ({ toString: () => "" }) }; + globalThis.requestAnimationFrame = (callback) => { frames.set(++nextFrame, callback); return nextFrame; }; + globalThis.cancelAnimationFrame = (id) => frames.delete(id); + const input = new NativeTerminalInput(async (action, params) => actions.push({ action, params }), (message) => errors.push(message), (value) => files.push(value), (value) => modifiers.push(value)); + const flush = () => { for (const [id, callback] of frames) { frames.delete(id); callback(); } }; + const edit = (inputType, data = null, cancelable = true) => { + const before = event("beforeinput", { inputType, data, isComposing: false }, cancelable); + input.element.dispatchEvent(before); + if (!before.defaultPrevented) { + const { value, selectionStart: start, selectionEnd: end } = input.element; + if (inputType.startsWith("delete")) { + const forward = inputType.endsWith("Forward"); + input.element.value = forward ? value.slice(0, start) + value.slice(Math.max(end, start + 1)) + : value.slice(0, Math.max(0, start - 1)) + value.slice(end); + } else input.element.value = value.slice(0, start) + (data ?? "") + value.slice(end); + input.element.dispatchEvent(event("input", { inputType, data, isComposing: false }, false)); + } + return before; + }; + const key = (value, modifiers = {}) => { + const down = event("keydown", { key: value, ctrlKey: false, altKey: false, metaKey: false, shiftKey: false, isComposing: false, ...modifiers }); + input.element.dispatchEvent(down); + return down; + }; + try { await run({ input, actions, errors, files, modifiers, edit, key, flush }); } + finally { + input.destroy(); + for (const [name, value] of saved) { if (value === undefined) delete globalThis[name]; else globalThis[name] = value; } + } +} + +test("held mobile Backspace keeps native editing alive and forwards once per repeat", () => withInput(({ input, actions, edit, key }) => { + for (let repeat = 0; repeat < 8; repeat += 1) { + assert.equal(key("Backspace", { repeat: repeat > 0 }).defaultPrevented, false); + assert.equal(edit("deleteContentBackward").defaultPrevented, false); + assert.equal(input.element.value, guard + guard); + assert.equal(input.element.selectionStart, 1); + } + assert.deepEqual(actions, Array.from({ length: 8 }, () => ({ action: "send_key", params: { key: "backspace" } }))); +})); + +test("input-only and noncancelable mobile deletion intents are forwarded once", () => withInput(({ input, actions, edit }) => { + for (const inputType of ["deleteContentForward", "deleteWordBackward", "deleteWordForward"]) edit(inputType, null, false); + input.element.value = guard; + input.element.dispatchEvent(event("input", { inputType: "deleteContentBackward", data: null }, false)); + assert.deepEqual(actions.map(({ params }) => params.key), ["delete", "ctrl-w", "alt-d", "backspace"]); + assert.equal(input.element.value, guard + guard); +})); + +test("desktop repeated keys and deletion modifiers keep their existing mappings", () => withInput(({ actions, key }) => { + for (const modifiers of [{}, { repeat: true }, { ctrlKey: true }, { altKey: true }, { metaKey: true }]) { + assert.equal(key("Backspace", modifiers).defaultPrevented, true); + } + assert.deepEqual(actions.map(({ params }) => params.key), ["backspace", "backspace", "ctrl-w", "ctrl-w", "ctrl-u"]); +}, false)); + +test("existing physical Ctrl shortcuts survive mobile toolbar support", () => withInput(({ actions, key }) => { + const letters = ["a", "b", "c", "d", "e", "f", "g", "k", "l", "n", "o", "p", "r", "t", "u", "w", "y"]; + for (const letter of letters) assert.equal(key(letter, { ctrlKey: true }).defaultPrevented, true); + assert.deepEqual(actions.map(({ params }) => params.key), letters.map((letter) => `ctrl-${letter}`)); + assert.equal(key("v", { ctrlKey: true }).defaultPrevented, false, "browser paste remains native"); +})); + +test("typing stays ordered before deletion without leaking browser guards", () => withInput(({ actions, edit, flush }) => { + edit("insertText", "hé🙂"); + edit("deleteContentBackward"); + flush(); + assert.deepEqual(actions, [ + { action: "type_literal", params: { text: "hé🙂" } }, + { action: "send_key", params: { key: "backspace" } }, + ]); +})); + +test("noncancelable and input-only insertion are sent exactly once", () => withInput(({ input, actions, edit, flush }) => { + edit("insertText", "abc", false); + input.element.value = `${guard}日本語${guard}`; + input.element.dispatchEvent(event("input", { inputType: "insertText", data: null }, false)); + flush(); + assert.deepEqual(actions, [{ action: "type_literal", params: { text: "abc日本語" } }]); +})); + +test("native paste and beforeinput/input fallbacks preserve paste semantics and Unicode", () => withInput(({ input, actions, edit }) => { + const text = `${guard}line one\nline two${guard}`; + const paste = event("paste", { clipboardData: { files: [], items: [], getData: () => text } }); + input.element.dispatchEvent(paste); + assert.equal(paste.defaultPrevented, true); + edit("insertFromPaste", text); + edit("insertFromPaste", text, false); + assert.deepEqual(actions, Array.from({ length: 3 }, () => ({ action: "paste_text", params: { text } }))); + assert.equal(input.element.value, guard + guard); +})); + +test("composition commits once and does not forward candidate editing", () => withInput(({ input, actions, edit, key, flush }) => { + input.element.dispatchEvent(event("compositionstart")); + input.element.value = `${guard}你好${guard}`; + input.element.dispatchEvent(event("input", { inputType: "insertCompositionText", data: "你好", isComposing: true }, false)); + key("Backspace", { isComposing: true }); + assert.deepEqual(actions, []); + input.element.dispatchEvent(event("compositionend", { data: "你好" })); + edit("insertText", "你好"); // Engines may send a final committed input too. + flush(); + assert.deepEqual(actions, [{ action: "type_literal", params: { text: "你好" } }]); + assert.equal(input.element.value, guard + guard); +})); + +test("file paste still uses uploads and oversized text remains bounded", () => withInput(({ input, actions, files, errors, edit }) => { + const image = { name: "image.png" }; + input.element.dispatchEvent(event("paste", { clipboardData: { files: [image], items: [], getData: () => "" } })); + edit("insertFromPaste", "x".repeat(256 * 1024 + 1)); + assert.deepEqual(files, [[image]]); + assert.deepEqual(actions, []); + assert.match(errors[0], /256 KiB/); +})); + +test("closing an input drops queued typing and sends no later keys", () => withInput(({ input, actions, edit, flush }) => { + edit("insertText", "not sent"); + input.destroy(); + flush(); + input.sendKey("backspace"); + assert.deepEqual(actions, []); +})); + +test("Ctrl is one-shot, keeps typing order, and accepts phone input without keydown", () => withInput(({ input, actions, modifiers, edit, flush }) => { + edit("insertText", "before"); + input.toggleModifier("ctrl"); + edit("insertText", "a"); + edit("insertText", "after"); + flush(); + assert.deepEqual(actions, [ + { action: "type_literal", params: { text: "before" } }, + { action: "send_key", params: { key: "ctrl-a" } }, + { action: "type_literal", params: { text: "after" } }, + ]); + assert.deepEqual(modifiers, [{ ctrl: true, shift: false }, { ctrl: false, shift: false }]); +})); + +test("Shift changes one typed character and modified Enter and Tab keep their identities", () => withInput(({ input, actions, edit, flush }) => { + input.toggleModifier("shift"); + edit("insertText", "a"); + edit("insertText", "b"); + input.toggleModifier("shift"); + edit("insertLineBreak"); + input.toggleModifier("shift"); + input.sendKey("tab"); + input.toggleModifier("shift"); + edit("insertText", "1"); + flush(); + assert.deepEqual(actions, [ + { action: "type_literal", params: { text: "Ab" } }, + { action: "send_key", params: { key: "shift-enter" } }, + { action: "send_key", params: { key: "backtab" } }, + { action: "type_literal", params: { text: "!" } }, + ]); +})); + +test("combined modifiers work for arrows and hardware Enter without sticking", () => withInput(({ input, actions, modifiers, key }) => { + input.toggleModifier("ctrl"); input.toggleModifier("shift"); + input.sendKey("left"); + input.sendKey("right"); + input.toggleModifier("ctrl"); input.toggleModifier("shift"); + assert.equal(key("Enter").defaultPrevented, true); + input.toggleModifier("ctrl"); + assert.equal(key("a").defaultPrevented, true); + assert.deepEqual(actions.map(({ params }) => params.key), ["ctrl-shift-left", "right", "ctrl-shift-enter", "ctrl-a"]); + assert.deepEqual(modifiers.at(-1), { ctrl: false, shift: false }); +})); + +test("Ctrl plus held phone Backspace deletes one word, then repeats ordinary Backspace", () => withInput(({ input, actions, edit, key }) => { + input.toggleModifier("ctrl"); + for (let repeat = 0; repeat < 3; repeat += 1) { + assert.equal(key("Backspace", { repeat: repeat > 0 }).defaultPrevented, false); + edit("deleteContentBackward"); + } + assert.deepEqual(actions.map(({ params }) => params.key), ["ctrl-w", "backspace", "backspace"]); +})); + +test("native clipboard shortcuts and paste clear modifiers without changing clipboard content", () => withInput(({ input, actions, modifiers, edit, key, flush }) => { + input.toggleModifier("ctrl"); input.toggleModifier("shift"); + edit("insertFromPaste", "unchanged\n日本語"); + input.toggleModifier("shift"); + assert.equal(key("v", { ctrlKey: true }).defaultPrevented, false); + edit("insertText", "v"); + input.toggleModifier("ctrl"); + assert.equal(key("c", { metaKey: true }).defaultPrevented, false); + flush(); + assert.deepEqual(actions, [{ action: "paste_text", params: { text: "unchanged\n日本語" } }, { action: "type_literal", params: { text: "v" } }]); + assert.deepEqual(modifiers.at(-1), { ctrl: false, shift: false }); +})); + +test("IME, blur and cancellation clear modifiers; invalid Ctrl text is not typed accidentally", () => withInput(({ input, actions, errors, modifiers, edit, flush }) => { + input.toggleModifier("ctrl"); input.toggleModifier("shift"); + input.element.dispatchEvent(event("compositionstart")); + input.element.dispatchEvent(event("compositionend", { data: "你好" })); + input.toggleModifier("shift"); input.blur(); + edit("insertText", "plain"); + input.toggleModifier("ctrl"); edit("insertText", "several letters"); + input.toggleModifier("ctrl"); input.toggleModifier("ctrl"); + edit("insertText", "normal"); + flush(); + assert.equal(errors.length, 1); + assert.deepEqual(actions, [{ action: "type_literal", params: { text: "你好" } }, { action: "type_literal", params: { text: "plainnormal" } }]); + assert.deepEqual(modifiers.at(-1), { ctrl: false, shift: false }); +})); + +test("phone keyboards that compose ASCII still deliver the armed Ctrl chord once", () => withInput(({ input, actions, modifiers, edit, key, flush }) => { + input.toggleModifier("ctrl"); input.toggleModifier("shift"); + input.element.dispatchEvent(event("compositionstart")); + edit("insertCompositionText", "a"); + key("Backspace", { isComposing: true }); + assert.deepEqual(actions, []); + input.element.dispatchEvent(event("compositionend", { data: "a" })); + edit("insertText", "a"); + flush(); + assert.deepEqual(actions, [{ action: "send_key", params: { key: "ctrl-shift-a" } }]); + assert.deepEqual(modifiers.at(-1), { ctrl: false, shift: false }); +})); + +test("selected-text Ctrl+C and AltGr do not become toolbar control chords", () => withInput(({ input, actions, modifiers, edit, key, flush }) => { + window.getSelection = () => ({ toString: () => "selected output" }); + input.toggleModifier("shift"); + assert.equal(key("c", { ctrlKey: true }).defaultPrevented, false); + input.toggleModifier("ctrl"); + assert.equal(key("€", { ctrlKey: true, altKey: true }).defaultPrevented, false); + edit("insertText", "€"); + flush(); + assert.deepEqual(actions, [{ action: "type_literal", params: { text: "€" } }]); + assert.deepEqual(modifiers.at(-1), { ctrl: false, shift: false }); +})); diff --git a/web/packages/app/test/native-keymap.test.mjs b/web/packages/app/test/native-keymap.test.mjs index 6147de810..e94ae3d34 100644 --- a/web/packages/app/test/native-keymap.test.mjs +++ b/web/packages/app/test/native-keymap.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { deletionInputKey, logicalKey } from "../dist/test/native-keymap.js"; +import { deletionInputKey, logicalKey, modifiedLogicalKey, shiftedText } from "../dist/test/native-keymap.js"; const key = (value, modifiers = {}) => ({ key: value, @@ -36,6 +36,33 @@ test("copy and paste remain browser-owned command shortcuts", () => { assert.equal(logicalKey(key("c", { metaKey: true })), undefined); assert.equal(logicalKey(key("v", { metaKey: true })), undefined); assert.equal(logicalKey(key("v", { ctrlKey: true })), undefined); + assert.equal(logicalKey(key("c", { ctrlKey: true, shiftKey: true })), undefined); +}); + +test("toolbar modifiers preserve terminal chords without raw escape sequences", () => { + const ctrl = { ctrl: true, shift: false }, shift = { ctrl: false, shift: true }, both = { ctrl: true, shift: true }; + for (const letter of "abcdefghijklmnopqrstuvwxyz") assert.equal(modifiedLogicalKey(letter, ctrl), `ctrl-${letter}`); + assert.equal(modifiedLogicalKey("A", both), "ctrl-shift-a"); + assert.equal(modifiedLogicalKey("space", ctrl), "ctrl-space"); + assert.equal(modifiedLogicalKey("enter", shift), "shift-enter"); + assert.equal(modifiedLogicalKey("tab", shift), "backtab"); + assert.equal(modifiedLogicalKey("tab", both), "ctrl-shift-tab"); + assert.equal(modifiedLogicalKey("left", ctrl), "ctrl-left"); + assert.equal(modifiedLogicalKey("right", both), "ctrl-shift-right"); + assert.equal(modifiedLogicalKey("backspace", ctrl), "ctrl-w"); + assert.equal(modifiedLogicalKey("delete", ctrl), "alt-d"); + assert.equal(modifiedLogicalKey("ctrl-c", shift), "ctrl-c", "dedicated interrupt keeps its meaning"); + for (const value of ["abc", "日本語", "raw-bytes", "\x1b[A"]) assert.equal(modifiedLogicalKey(value, ctrl), undefined); +}); + +test("one-shot Shift affects one ASCII character and preserves native Unicode", () => { + assert.equal(shiftedText("abc"), "Abc"); + assert.equal(shiftedText("1rest"), "!rest"); + assert.equal(shiftedText("["), "{"); + assert.equal(shiftedText("\\"), "|"); + assert.equal(shiftedText(";"), ":"); + assert.equal(shiftedText("'"), '"'); + assert.equal(shiftedText("🙂日本語"), "🙂日本語"); }); test("beforeinput deletion intents cover words and both halves of a line", () => { diff --git a/web/packages/app/tsconfig.test.json b/web/packages/app/tsconfig.test.json index 0254a0a69..61259e4ed 100644 --- a/web/packages/app/tsconfig.test.json +++ b/web/packages/app/tsconfig.test.json @@ -8,5 +8,5 @@ "lib": ["ES2022", "DOM"], "declaration": false }, - "include": ["src/browser-tickets.ts", "src/dashboard-agents.ts", "src/file-upload.ts", "src/native-keymap.ts", "src/pairing.ts", "src/pairing-qr.ts", "src/render-scheduler.ts", "src/terminal-capabilities.ts", "src/terminal-output.ts", "src/terminal-pane-options.ts", "src/terminal-reconnect.ts", "src/terminal-target.ts", "src/terminal-viewport.ts", "src/view-state.ts"] + "include": ["src/browser-tickets.ts", "src/dashboard-agents.ts", "src/file-upload.ts", "src/native-input.ts", "src/native-keymap.ts", "src/pairing.ts", "src/pairing-qr.ts", "src/render-scheduler.ts", "src/terminal-capabilities.ts", "src/terminal-output.ts", "src/terminal-pane-options.ts", "src/terminal-reconnect.ts", "src/terminal-target.ts", "src/terminal-viewport.ts", "src/view-state.ts"] } diff --git a/web/scripts/check-browser-layout.mjs b/web/scripts/check-browser-layout.mjs index a3931543e..6fc531462 100644 --- a/web/scripts/check-browser-layout.mjs +++ b/web/scripts/check-browser-layout.mjs @@ -7,13 +7,18 @@ export async function checkBrowserLayout({ cdp, sessionId, evaluate, until, scre const run = (expression) => evaluate(sessionId, expression); const streamErrors = []; const viewportRequests = new Map(); + const inputActions = []; + let captureInput = false; let acceptedViewport; await cdp.call("Network.enable", {}, sessionId); cdp.events.on("Network.webSocketFrameSent", (event) => { - if (!checkViewport || event.sessionId !== sessionId) return; + if (event.sessionId !== sessionId) return; try { const frame = JSON.parse(event.params.response.payloadData); - if (frame.type === "stream.action" && frame.action === "set_viewport" && viewportRequests.size < 64) { + if (captureInput && frame.type === "stream.action" && ["type_literal", "paste_text", "send_key"].includes(frame.action) && inputActions.length < 64) { + inputActions.push({ action: frame.action, params: frame.params }); + } + if (checkViewport && frame.type === "stream.action" && frame.action === "set_viewport" && viewportRequests.size < 64) { viewportRequests.set(frame.id, frame.params); } } catch { /* Non-JSON network messages are not viewport requests. */ } @@ -56,7 +61,7 @@ export async function checkBrowserLayout({ cdp, sessionId, evaluate, until, scre await run("new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))"); if (await run("!!document.querySelector('.terminal-output')")) await terminalViewport(); }; - const click = async (selector) => { + const targetPoint = async (selector) => { const point = await run(`(() => { const target = document.querySelector(${JSON.stringify(selector)}); if (!target || target.disabled) return null; @@ -65,10 +70,19 @@ export async function checkBrowserLayout({ cdp, sessionId, evaluate, until, scre return { x, y, visible: rect.width > 0 && rect.height > 0 && target.contains(document.elementFromPoint(x, y)) }; })()`); assert.ok(point?.visible, `Clickable ${selector}`); + return point; + }; + const click = async (selector) => { + const point = await targetPoint(selector); for (const type of ["mousePressed", "mouseReleased"]) { await cdp.call("Input.dispatchMouseEvent", { type, x: point.x, y: point.y, button: "left", clickCount: 1 }, sessionId); } }; + const tap = async (selector) => { + const point = await targetPoint(selector); + await cdp.call("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: point.x, y: point.y }] }, sessionId); + await cdp.call("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }, sessionId); + }; const key = async (key, code, windowsVirtualKeyCode) => { for (const type of ["keyDown", "keyUp"]) await cdp.call("Input.dispatchKeyEvent", { type, key, code, windowsVirtualKeyCode }, sessionId); }; @@ -77,6 +91,32 @@ export async function checkBrowserLayout({ cdp, sessionId, evaluate, until, scre const result = await cdp.call("Page.captureScreenshot", { format: "png" }, sessionId); await writeFile(path.join(screenshots, `${name}.png`), Buffer.from(result.data, "base64")); }; + const sessionDropdown = async () => { + const open = async () => { + await click('.hero-session'); + await until(() => run("!!document.querySelector('.session-menu-list') && document.querySelector('.hero-session')?.getAttribute('aria-expanded') === 'true'")); + }; + const closed = () => until(() => run("!document.querySelector('.session-menu') && document.querySelector('.hero-session')?.getAttribute('aria-expanded') === 'false'")); + await open(); + await click('.session-menu-head strong'); + assert.equal(await run("!!document.querySelector('.session-menu')"), true, "clicks inside the session dropdown keep it open"); + await click('.hero-center > h1'); + await closed(); + if (await run("matchMedia('(min-width: 1024px)').matches")) { + await open(); + await click('[data-view-key="pane-filter:true"]'); + await closed(); + assert.equal(await run("document.querySelector('[data-view-key=\"pane-filter:true\"]')?.getAttribute('aria-pressed')"), "true", "outside clicks still activate the clicked control"); + await click('[data-view-key="pane-filter:false"]'); + } + await open(); + await click('.hero-session'); + await closed(); + await open(); + await key("Escape", "Escape", 27); + await closed(); + assert.equal(await run("document.activeElement === document.querySelector('.hero-session')"), true, "Escape returns focus to the session trigger"); + }; const terminalReady = async () => { try { await until(() => run("!!document.querySelector('.terminal-content')?.textContent")); @@ -99,12 +139,146 @@ export async function checkBrowserLayout({ cdp, sessionId, evaluate, until, scre fits: bar.left >= 0 && bar.right <= innerWidth + 1, }; })()`); - assert.deepEqual(controls.actions, ["Attach files", "Show or hide keyboard", "escape", "tab", "up", "down", "left", "right", "Control C"], "action bar retains terminal controls without a wrap/layout toggle"); + assert.deepEqual(controls.actions, ["Attach files", "Show or hide keyboard", "Control modifier", "Shift modifier", "escape", "tab", "enter", "up", "down", "left", "right", "Control C"], "action bar retains terminal keys and modifiers without a wrap/layout toggle"); assert.equal(controls.wrapping, "pre-wrap", "default wrapping stays enabled"); assert.equal(controls.overflowWrap, "anywhere", "long output fits the browser width"); assert.equal(controls.fits, true, "action bar fits the viewport"); await screenshot(screenshotName); await click('[aria-label="Hide terminal controls"]'); + assert.equal(await run("document.querySelector('.terminal-tools').inert"), true, "collapsed keys are not invisible tab targets"); + }; + const mobileInput = async () => { + const field = await run(`(() => { + const input = document.querySelector('.terminal-input-proxy'); + const box = input.getBoundingClientRect(), style = getComputedStyle(input); + const x = box.left + box.width / 2, y = box.top + box.height / 2; + return {x, y, height: box.height, width: box.width, touchable: document.elementFromPoint(x, y) === input, + opacity: style.opacity, pointerEvents: style.pointerEvents, fontSize: style.fontSize, + editable: !input.readOnly && !input.disabled, coarse: matchMedia('(pointer: coarse)').matches}; + })()`); + assert.ok(field.height >= 44 && field.width >= 100, "mobile input has a real long-press target"); + assert.equal(field.touchable && field.editable && field.coarse, true); + assert.equal(field.pointerEvents, "auto"); + assert.equal(field.opacity, "1"); + assert.equal(field.fontSize, "16px", "focusing input does not request iOS small-text zoom"); + await cdp.call("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: field.x, y: field.y }] }, sessionId); + await cdp.call("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }, sessionId); + await until(() => run("document.activeElement === document.querySelector('.terminal-input-proxy')")); + inputActions.length = 0; + captureInput = true; + const typed = "printf 'WEB_MOBILE_%s\\n' repeatZZZZZ"; + await cdp.call("Input.insertText", { text: typed }, sessionId); + for (let repeat = 0; repeat < 5; repeat += 1) { + await cdp.call("Input.dispatchKeyEvent", { type: "keyDown", key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8, autoRepeat: repeat > 0 }, sessionId); + } + await cdp.call("Input.dispatchKeyEvent", { type: "keyUp", key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 }, sessionId); + await key("Enter", "Enter", 13); + await until(() => run("document.querySelector('.terminal-content')?.textContent?.split(/\\r?\\n/).includes('WEB_MOBILE_repeat')")); + captureInput = false; + assert.deepEqual(inputActions.filter(({ action }) => action === "type_literal"), [{ action: "type_literal", params: { text: typed } }], "typing does not leak browser-only guards"); + assert.equal(inputActions.filter(({ action, params }) => action === "send_key" && params.key === "backspace").length, 5, "held Backspace forwards exactly one key per repeat"); + inputActions.length = 0; + captureInput = true; + // Exercise the native paste handler without reading or overwriting the + // operator's system clipboard. The command must reach the actual PTY. + const pasted = "printf 'WEB_PASTE_%s\\n' pasted"; + assert.equal(await run(`(() => { + const clipboardData = new DataTransfer(); + clipboardData.setData('text/plain', ${JSON.stringify(pasted)}); + return document.querySelector('.terminal-input-proxy').dispatchEvent(new ClipboardEvent('paste', {clipboardData, bubbles: true, cancelable: true})); + })()`), false, "native paste is handled by terminal input"); + await key("Enter", "Enter", 13); + await until(() => run("document.querySelector('.terminal-content')?.textContent?.split(/\\r?\\n/).includes('WEB_PASTE_pasted')")); + captureInput = false; + assert.deepEqual(inputActions.filter(({ action }) => action === "paste_text"), [{ action: "paste_text", params: { text: pasted } }], "paste reaches the PTY once with paste semantics"); + inputActions.length = 0; + captureInput = true; + const prefix = "printf 'WEB_IME_%s\\n' "; + await cdp.call("Input.insertText", { text: prefix }, sessionId); + await cdp.call("Input.imeSetComposition", { text: "日本語", selectionStart: 3, selectionEnd: 3 }, sessionId); + await cdp.call("Input.insertText", { text: "日本語" }, sessionId); + await key("Enter", "Enter", 13); + await until(() => run("document.querySelector('.terminal-content')?.textContent?.split(/\\r?\\n/).includes('WEB_IME_日本語')")); + captureInput = false; + assert.equal(inputActions.filter(({ action }) => action === "type_literal").map(({ params }) => params.text).join(""), prefix + "日本語", "native IME commits once without leaking the guards"); + console.log("mobile browser input passed: touchable paste target, native held-Backspace repeats, paste handler, and IME all reach the real debug PTY"); + await screenshot("terminal-mobile-input"); + }; + const mobileModifiers = async () => { + await tap('[aria-label="Show terminal controls"]'); + await until(() => run("getComputedStyle(document.querySelector('.terminal-tools')).opacity === '1'")); + await tap('[aria-label="Show or hide keyboard"]'); + assert.equal(await run("document.activeElement === document.querySelector('.terminal-input-proxy')"), false, "keyboard toggle blurs the input instead of re-focusing it on touch"); + await tap('[aria-label="Show or hide keyboard"]'); + assert.equal(await run("document.activeElement === document.querySelector('.terminal-input-proxy')"), true); + const modifier = async (label) => { + await tap(`[aria-label="${label} modifier"]`); + assert.equal(await run(`document.querySelector('[aria-label="${label} modifier"]').getAttribute('aria-pressed')`), "true"); + assert.equal(await run("document.activeElement === document.querySelector('.terminal-input-proxy')"), true, "touching a modifier keeps the keyboard focused"); + }; + const clear = async () => { + assert.equal(await run("Array.from(document.querySelectorAll('.terminal-modifier')).every(button => button.getAttribute('aria-pressed') === 'false')"), true, "modifiers clear after one terminal key"); + }; + const text = async (value) => cdp.call("Input.insertText", { text: value }, sessionId); + captureInput = true; + inputActions.length = 0; + await text("printf 'WEB_SHIFT_%s\\n' "); + await modifier("Shift"); + await text("a"); + await clear(); + await text("fter"); + await tap('[aria-label="enter"]'); + await until(() => run("document.querySelector('.terminal-content')?.textContent?.split(/\\r?\\n/).includes('WEB_SHIFT_After')")); + await text("printf 'WEB_CTRL_%s\\n' wrong"); + await modifier("Control"); + await text("w"); + await clear(); + await text("correct"); + await tap('[aria-label="enter"]'); + await until(() => run("document.querySelector('.terminal-content')?.textContent?.split(/\\r?\\n/).includes('WEB_CTRL_correct')")); + captureInput = false; + assert.equal(inputActions.filter(({ params }) => params.key === "ctrl-w").length, 1, "Ctrl plus a phone letter sends one control key"); + assert.ok(inputActions.some(({ action, params }) => action === "type_literal" && params.text.includes("A")), "Shift types an uppercase character"); + + // A real PTY consumer negotiates Kitty input, reads an exact bounded byte + // count, and restores its tty/protocol on exit. No production pane is used. + for (const flags of [1, 3]) { + const expected = Buffer.from(flags === 1 + ? "\x1b[13;2u\x1b[13;5u\x1b[13;6u\x1b[Z\x1b[1;5D" + : "\x1b[13;2u\x1b[13;2:3u\x1b[13;5u\x1b[13;5:3u\x1b[13;6u\x1b[13;6:3u\x1b[Z\x1b[1;5D\x1b[1;5:3D"); + const probe = `sh -c 'mode=$(stty -g); trap "stty $mode; printf \\\"\\\\033[${flags}uWEB_KEYS_READY_${flags}\\n"; captured=$(od -An -v -t x1 -N ${expected.length} | tr -d " \\n"); printf "WEB_KEYS_HEX_${flags}:%s:END\\n" "$captured"'`; + await text(probe); + await tap('[aria-label="enter"]'); + await until(() => run(`document.querySelector('.terminal-content')?.textContent?.split(/\\r?\\n/).includes('WEB_KEYS_READY_${flags}')`)); + for (const modifiers of [["Shift"], ["Control"], ["Control", "Shift"]]) { + for (const label of modifiers) await modifier(label); + await tap('[aria-label="enter"]'); + await clear(); + } + await modifier("Shift"); await tap('[aria-label="tab"]'); await clear(); + await modifier("Control"); await tap('[aria-label="left"]'); await clear(); + const received = () => run(`document.querySelector('.terminal-content')?.textContent?.match(/WEB_KEYS_HEX_${flags}:([0-9a-f\\s]+):END/)?.[1]`); + await until(received); + assert.equal((await received()).replace(/\s/g, ""), expected.toString("hex"), "modified keys preserve negotiated identities and release phases at the actual PTY"); + } + await text("printf 'WEB_KEYS_%s\\n' restored"); + await tap('[aria-label="enter"]'); + await until(() => run("document.querySelector('.terminal-content')?.textContent?.split(/\\r?\\n/).includes('WEB_KEYS_restored')")); + await modifier("Control"); + await tap('[aria-label="Hide terminal controls"]'); + await clear(); + for (const width of [390, 320]) { + await viewport(width, 568, true); + await tap('[aria-label="Show terminal controls"]'); + await until(() => run("getComputedStyle(document.querySelector('.terminal-tools')).opacity === '1'")); + const targets = await run("Array.from(document.querySelectorAll('.terminal-tools button'), button => { const rect=button.getBoundingClientRect(); return {width:rect.width,height:rect.height,left:rect.left,right:rect.right}; })"); + assert.ok(targets.every(({ width: targetWidth, height, left, right }) => targetWidth >= 44 && height >= 44 && left >= 0 && right <= width), "every mobile key is touch-sized and visible without horizontal scrolling"); + await until(() => run("(() => { const caret=document.querySelector('.terminal-caret'), dock=document.querySelector('.terminal-controls-wrap'); if(!caret) return false; const box=caret.getBoundingClientRect(); return box.top >= 0 && box.bottom <= dock.getBoundingClientRect().top; })()")); + await screenshot(`terminal-mobile-modifiers-${width}`); + await tap('[aria-label="Hide terminal controls"]'); + } + await viewport(390, 844, true); + console.log("mobile modifiers passed: real touch Ctrl/Shift, one-shot state, shell editing, negotiated Kitty Enter/Tab/arrows, and all 44px keys visible at 320/390px"); }; const breakpoint = async (view) => { // CDP accepts integer viewport widths. Also inspect the browser-parsed @@ -180,6 +354,7 @@ export async function checkBrowserLayout({ cdp, sessionId, evaluate, until, scre await viewport(width, 900); await desktop("dashboard"); } + await sessionDropdown(); await breakpoint("dashboard"); await screenshot("dashboard-desktop"); await click('[aria-label="Collapse workspaces sidebar"]'); @@ -221,6 +396,8 @@ export async function checkBrowserLayout({ cdp, sessionId, evaluate, until, scre await viewport(390, 844, true); await until(() => run("!!document.querySelector('#terminal-navigation > .terminal-sidebar')")); assert.equal(await run("getComputedStyle(document.querySelector('.web-content-frame')).display"), "contents"); + await mobileInput(); + await mobileModifiers(); await terminalControls("terminal-mobile-controls"); await click('.terminal-header [aria-label="Open navigation"]'); await until(() => run("document.querySelector('#terminal-navigation')?.contains(document.activeElement)")); @@ -241,9 +418,10 @@ export async function checkBrowserLayout({ cdp, sessionId, evaluate, until, scre assert.equal(await run("!!document.querySelector('#dashboard-navigation > .terminal-sidebar') && document.querySelector('.dashboard-header').inert"), true); await screenshot("dashboard-mobile-drawer"); await key("Escape", "Escape", 27); + await sessionDropdown(); await viewport(1440, 900); await desktop("dashboard"); await click('[data-view-key="pane-filter:false"]'); - console.log("browser layout passed: header-free desktop frame, aligned compact rails, both collapsed rails, compact action bar with default wrapping, live terminal input, mobile drawers, complementary breakpoint, and short viewport"); + console.log("browser layout passed: header-free desktop frame, aligned compact rails, both collapsed rails, compact action bar with default wrapping, live terminal input, mobile drawers, session dropdown inside/outside clicks and Escape on desktop/mobile, complementary breakpoint, and short viewport"); if (checkViewport) console.log("browser viewport sizing passed: window, sidebar, and mobile viewport changes reach the real debug server"); } diff --git a/web/scripts/test-browser-pairing.mjs b/web/scripts/test-browser-pairing.mjs index 3a53b9df5..91663b3b9 100644 --- a/web/scripts/test-browser-pairing.mjs +++ b/web/scripts/test-browser-pairing.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; import { EventEmitter } from "node:events"; -import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import readline from "node:readline"; import { fileURLToPath } from "node:url"; @@ -72,9 +72,11 @@ try { await foreground(restarted); await evaluate(restarted.sessionId, "document.querySelector('[aria-label=Devices]').click()"); await until(() => evaluate(restarted.sessionId, "!!document.querySelector('.device-pair:not(:disabled)')")); + await checkDeviceFooter(restarted, false); await evaluate(restarted.sessionId, "document.querySelector('.device-pair').click()"); await until(() => evaluate(restarted.sessionId, "!!document.querySelector('.pairing-link')?.value")); const phoneLink = await evaluate(restarted.sessionId, "document.querySelector('.pairing-link').value"); + await checkDeviceFooter(restarted, true); const { browserContextId } = await cdp.call("Target.createBrowserContext"); const phone = await page(phoneLink, browserContextId); await ready(phone); @@ -84,7 +86,8 @@ try { // frames. Activate the tab before checking its newly painted device count. await foreground(restarted); await until(() => evaluate(restarted.sessionId, "document.querySelector('.device-copy')?.textContent?.startsWith('2 authorized') && !!document.querySelector('.device-disconnect:not(:disabled)')")); - assert.equal(await evaluate(restarted.sessionId, "document.querySelector('.device-disconnect')?.textContent"), "Disconnect this browser"); + assert.equal(await evaluate(restarted.sessionId, "document.querySelector('.device-disconnect')?.textContent"), "Disconnect"); + assert.equal(await evaluate(restarted.sessionId, "document.querySelector('.device-disconnect')?.getAttribute('aria-label')"), "Disconnect this browser"); await cdp.call("Network.enable", {}, restarted.sessionId); let forgetRequests = 0; cdp.events.on("Network.webSocketFrameSent", (event) => { @@ -120,7 +123,7 @@ try { await ready(phone); await until(() => evaluate(phone.sessionId, "document.querySelector('.device-copy')?.textContent?.startsWith('1 authorized')")); assert.equal(exceptions.length, 0, `Unexpected browser exceptions: ${exceptions.length}`); - console.log("real Chromium browser passed: one QR/code pairing across eight tabs, close/reopen tabs, browser restart, separate profile, disconnect cancellation, and cross-tab revocation"); + console.log("real Chromium browser passed: one QR/code pairing across eight tabs, close/reopen tabs, browser restart, separate profile, desktop/mobile device footer, disconnect cancellation, and cross-tab revocation"); } finally { await closeBrowser().catch(() => browser?.kill("SIGKILL")); if (bridge?.exitCode === null) { bridge.kill("SIGINT"); await exited(bridge).catch(() => bridge.kill("SIGKILL")); } @@ -130,6 +133,62 @@ try { await rm(home, { recursive: true, force: true }); } +async function checkDeviceFooter(page, pairingOpen) { + try { + for (const [width, height, mobile] of [[1440, 900, false], [390, 844, true], [320, 568, true]]) { + await cdp.call("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile }, page.sessionId); + await until(() => evaluate(page.sessionId, `innerWidth === ${width} && innerHeight === ${height}`)); + await evaluate(page.sessionId, "document.querySelector('.device-browser-access').scrollIntoView({block: 'nearest'})"); + await evaluate(page.sessionId, "new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))"); + if (pairingOpen && mobile) { + // Saving the unchanged limit causes a real live redraw without changing + // authority. A scrolled QR panel must retain access to its footer. + const before = await evaluate(page.sessionId, `(() => { + const panel = document.querySelector('.device-panel'); + panel.setAttribute('data-qa-before-refresh', ''); + const scroll = panel.scrollTop; + document.querySelector('.device-limit').dispatchEvent(new Event('change', {bubbles: true})); + return scroll; + })()`); + assert.ok(before > 0, "short mobile QR panel needs scrolling"); + await until(() => evaluate(page.sessionId, "!!document.querySelector('.device-disconnect:not(:disabled)') && !document.querySelector('.device-panel')?.hasAttribute('data-qa-before-refresh')")); + assert.equal(await evaluate(page.sessionId, "document.querySelector('.device-panel').scrollTop"), before, "live redraw preserves the device panel scroll position"); + } + const geometry = await evaluate(page.sessionId, `(() => { + const panel = document.querySelector('.device-panel').getBoundingClientRect(); + const footer = document.querySelector('.device-browser-access').getBoundingClientRect(); + const info = document.querySelector('.device-browser-info').getBoundingClientRect(); + const button = document.querySelector('.device-disconnect'); + const control = button.getBoundingClientRect(); + const previous = document.querySelector('${pairingOpen ? ".pairing-card" : ".device-pair"}').getBoundingClientRect(); + return { + panelFits: panel.top >= 0 && panel.bottom <= innerHeight + 1, + footerFits: footer.left >= panel.left && footer.right <= panel.right && footer.bottom <= panel.bottom, + separated: footer.top - previous.bottom, + buttonHeight: control.height, + infoGap: control.left - info.right, + clickable: button.contains(document.elementFromPoint(control.left + control.width / 2, control.top + control.height / 2)), + panel: {top: panel.top, bottom: panel.bottom, left: panel.left, right: panel.right}, + clip: {x: footer.left, y: footer.top, width: footer.width, height: footer.height, scale: 1}, + }; + })()`); + assert.equal(geometry.panelFits, true, `device panel fits ${width}×${height}`); + assert.equal(geometry.footerFits, true, `browser access stays inside the device panel at ${width}×${height}: ${JSON.stringify({ panel: geometry.panel, footer: geometry.clip })}`); + assert.ok(geometry.separated >= 16, "disconnect footer is separated from pairing actions"); + assert.ok(geometry.buttonHeight >= 44, "disconnect retains a touch-sized target"); + assert.ok(geometry.infoGap >= 12, "browser label and disconnect button do not overlap"); + assert.equal(geometry.clickable, true, "disconnect remains reachable on short mobile screens"); + if (screenshots) { + // Capture only the footer, never an unspent pairing URL or QR code. + const result = await cdp.call("Page.captureScreenshot", { format: "png", clip: geometry.clip, captureBeyondViewport: false }, page.sessionId); + await writeFile(path.join(screenshots, `device-footer-${pairingOpen ? "pairing" : "idle"}-${width}.png`), Buffer.from(result.data, "base64")); + } + } + } finally { + await cdp.call("Emulation.clearDeviceMetricsOverride", {}, page.sessionId); + } +} + async function launchBrowser() { await rm(path.join(profile, "DevToolsActivePort"), { force: true }); browser = spawn(browserBin, ["--headless=new", "--remote-debugging-port=0", `--user-data-dir=${profile}`, "--no-first-run", "--no-default-browser-check", "--disable-background-networking", "--disable-component-update", ...(process.platform === "linux" ? ["--no-sandbox"] : []), "about:blank"], { stdio: "ignore", windowsHide: true }); diff --git a/website/src/content/docs/docs/guides/web.mdx b/website/src/content/docs/docs/guides/web.mdx index 5fabaa8e2..92d41de69 100644 --- a/website/src/content/docs/docs/guides/web.mdx +++ b/website/src/content/docs/docs/guides/web.mdx @@ -233,6 +233,22 @@ mobile keyboards write to the actual PTY, so the running shell or agent still owns editing, history, cursor movement, and Tab completion. The compact control dock supplies keys that are awkward on touch keyboards. +On touch devices, the compact input field beside the controls supports the +browser's native long-press **Paste** menu. Hold the keyboard's Backspace key +to delete continuously. Long-press terminal output itself to select and copy +output; the input field does not replace the shell's own editing state. + +Expand the floating controls to use **Ctrl**, **Shift**, **Enter**, **Tab**, +**Esc**, and the arrows. Ctrl and Shift are one-shot: tap either or both, +then type a letter or tap a terminal key. For example, Ctrl then `a` moves to +the start of a shell command, Shift then Tab sends Backtab, and Shift then +Enter sends Shift+Enter (the configured newline sequence in legacy keyboard +mode). Tap an armed modifier again to cancel it. Paste, committing IME input, +closing the controls, and leaving the input clear modifiers; pasted text and +non-ASCII IME input are never changed. Touch controls wrap into rows instead +of hiding keys off-screen. Modifier buttons are disabled when the selected +server does not advertise `modified_keys`. + Useful editing shortcuts include: - Alt/Option+Backspace or Ctrl+Backspace: delete the previous word; diff --git a/website/src/content/docs/docs/uhp/terminal.mdx b/website/src/content/docs/docs/uhp/terminal.mdx index 47b1942e8..fc2b37be2 100644 --- a/website/src/content/docs/docs/uhp/terminal.mdx +++ b/website/src/content/docs/docs/uhp/terminal.mdx @@ -187,8 +187,19 @@ backward and forward word deletion, and `ctrl-u` and `ctrl-k` for deletion toward the start and end of the current line. It also includes `ctrl-a`, `ctrl-b`, `ctrl-e`, `ctrl-f`, `ctrl-g`, `ctrl-l`, `ctrl-n`, `ctrl-o`, `ctrl-p`, `ctrl-r`, `ctrl-t`, and `ctrl-y` for line -movement, history search, screen clearing, and agent shortcuts. Job-control and -flow-control keys such as `ctrl-z`, `ctrl-s`, and `ctrl-q` are not accepted. +movement, history search, screen clearing, and agent shortcuts. On servers +without `modified_keys`, job-control and flow-control keys such as `ctrl-z`, +`ctrl-s`, and `ctrl-q` are not accepted. + +Servers advertising the `modified_keys` terminal feature also accept `ctrl-a` +through `ctrl-z`, `ctrl-shift-a` through `ctrl-shift-z`, `ctrl-space` and +`ctrl-shift-space`. Prefix Enter, Tab, arrows, Home, End, PageUp or PageDown +with `ctrl-`, `shift-` or `ctrl-shift-` (for example, `shift-enter` or +`ctrl-left`). The server uses the pane's negotiated keyboard mode; legacy +Shift+Enter uses Luvus's configured newline sequence. Raw escape sequences +remain unsupported. +Each action is a complete key tap, including the release when the child +requests Kitty key-event reporting. `paste_image` takes `{png_base64}` for one validated PNG up to 160 KiB. Luvus stages the image beneath private selected-session storage and delivers its From 9cd3ee0afcf74400f89e09ce0c6da30934fda48d Mon Sep 17 00:00:00 2001 From: "Paulo R. Kraemer" Date: Wed, 21 Oct 2026 08:55:39 -0300 Subject: [PATCH 4/5] fix(integration): run Claude and Codex hooks through PowerShell on Windows (#511) --- src/agent/claude/hook.ps1 | 25 + src/agent/claude/integration.rs | 225 +++++++- src/agent/codex/hook.ps1 | 19 + src/agent/codex/integration.rs | 58 +- src/agent/devin/integration.rs | 46 +- src/integration.rs | 507 +++++++++++++++++- .../src/content/docs/docs/guides/agents.mdx | 5 +- 7 files changed, 823 insertions(+), 62 deletions(-) create mode 100644 src/agent/claude/hook.ps1 create mode 100644 src/agent/codex/hook.ps1 diff --git a/src/agent/claude/hook.ps1 b/src/agent/claude/hook.ps1 new file mode 100644 index 000000000..7acdbdb0f --- /dev/null +++ b/src/agent/claude/hook.ps1 @@ -0,0 +1,25 @@ +# Luvus Claude Code integration for Windows. Claude Code passes one hook JSON +# payload on stdin, and the Luvus binary reports the session or lifecycle event +# to the pane running it (`luvus integration hook claude`), so the hook needs +# neither python3 nor a POSIX shell. + +if ($env:LUVUS_ENV -ne "1" -or [string]::IsNullOrWhiteSpace($env:LUVUS_SOCKET_PATH)) { + exit 0 +} +# Devin imports Claude-format hooks (global and project `.claude/settings.json`) +# and marks every hook process it spawns. Never report its sessions as ours. +if (-not [string]::IsNullOrEmpty($env:DEVIN_PROJECT_DIR)) { + exit 0 +} + +$luvus = $env:LUVUS_BIN_PATH +if ([string]::IsNullOrWhiteSpace($luvus) -or -not (Test-Path -LiteralPath $luvus -PathType Leaf)) { + $luvus = "luvus" +} +try { + & $luvus integration hook claude *> $null +} catch { + # Hooks are optional telemetry into the local Luvus server. Never interrupt + # Claude Code when the server or installed binary is unavailable. +} +exit 0 diff --git a/src/agent/claude/integration.rs b/src/agent/claude/integration.rs index 5625585d5..067978394 100644 --- a/src/agent/claude/integration.rs +++ b/src/agent/claude/integration.rs @@ -1,8 +1,13 @@ +use std::ffi::{OsStr, OsString}; +#[cfg(not(windows))] use std::fs; +use std::io::{self, Read}; use std::path::PathBuf; use anyhow::Result; -use serde_json::{json, Value}; +#[cfg(not(windows))] +use serde_json::json; +use serde_json::{Map, Value}; use super::super::types::IntegrationOperations; use crate::integration::{self, ShellHookSpec}; @@ -11,9 +16,20 @@ pub(super) const OPERATIONS: IntegrationOperations = IntegrationOperations { install, uninstall, is_installed, - hook: None, + hook: Some(run_hook), }; +/// Hook events registered besides `SessionStart`. +const EVENTS: [&str; 3] = ["Notification", "Stop", "UserPromptSubmit"]; +/// A submitted prompt rides in the UserPromptSubmit payload, so the bound is +/// wide. A larger payload is ignored. +const MAX_HOOK_PAYLOAD: u64 = 1024 * 1024; +/// The longest lifecycle message forwarded, in characters. +const MAX_MESSAGE_CHARS: usize = 200; + +#[cfg(any(windows, test))] +const POWERSHELL_SCRIPT: &str = include_str!("hook.ps1"); + fn config_dir() -> PathBuf { std::env::var_os("CLAUDE_CONFIG_DIR") .map(PathBuf::from) @@ -29,6 +45,7 @@ fn spec() -> ShellHookSpec { } } +#[cfg(not(windows))] fn install() -> Result<()> { let dir = integration::install_shell_hook_with_spec("claude", spec())?; let config = dir.join("settings.json"); @@ -37,17 +54,215 @@ fn install() -> Result<()> { .ok() .and_then(|contents| serde_json::from_str(&contents).ok()) .unwrap_or_else(|| json!({})); - for event in ["Notification", "Stop", "UserPromptSubmit"] { + for event in EVENTS { integration::register_hook(&mut value, event, None, &script.to_string_lossy(), None); } fs::write(config, serde_json::to_string_pretty(&value)?)?; Ok(()) } +/// Claude Code runs Windows hook commands through Git Bash, which drops the +/// backslashes of a raw path and splits it at spaces, so Windows registers an +/// encoded PowerShell command and a script that needs no python3. +#[cfg(windows)] +fn install() -> Result<()> { + let events = EVENTS.map(|event| (event, None)); + integration::install_powershell_hook(&spec(), POWERSHELL_SCRIPT, &events) +} + fn uninstall() -> Result<()> { - integration::uninstall_shell_hook(spec(), &["Notification", "Stop", "UserPromptSubmit"]) + integration::uninstall_shell_hook(spec(), &EVENTS) +} + +#[cfg(not(windows))] +fn is_installed() -> bool { + integration::shell_hook_installed(spec(), &EVENTS) } +#[cfg(windows)] fn is_installed() -> bool { - integration::shell_hook_installed(spec(), &["Notification", "Stop", "UserPromptSubmit"]) + integration::powershell_hook_installed(&spec(), &EVENTS) +} + +/// The hook runs only inside a Luvus pane, and never for Devin, which imports +/// Claude-format hooks and sets `DEVIN_PROJECT_DIR` for every hook process. +fn environment_matches(var: impl Fn(&str) -> Option) -> bool { + var("LUVUS_ENV").as_deref() == Some(OsStr::new("1")) + && var("LUVUS_SOCKET_PATH").is_some_and(|value| !value.is_empty()) + && var("DEVIN_PROJECT_DIR").is_none_or(|value| value.is_empty()) +} + +/// The request one Claude hook payload makes, matching the shell hook: a +/// lifecycle event becomes `pane.report_event`, and any other event with a +/// session id becomes `pane.report_session`. +fn hook_request(input: &[u8], pane: Option<&str>) -> Option<(&'static str, Value)> { + if input.len() as u64 > MAX_HOOK_PAYLOAD { + return None; + } + let payload: Value = serde_json::from_slice(input).ok()?; + let text = |key: &str| { + payload + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + }; + let mut params = Map::new(); + params.insert("agent".into(), super::DESCRIPTOR.id.into()); + let event = text("hook_event_name") + .or_else(|| text("event")) + .unwrap_or(""); + let method = if matches!( + event, + "Notification" | "Stop" | "SubagentStop" | "UserPromptSubmit" + ) { + let message: String = text("message") + .unwrap_or("") + .chars() + .take(MAX_MESSAGE_CHARS) + .collect(); + params.insert("kind".into(), event.into()); + params.insert("message".into(), message.into()); + params.insert( + "notification_type".into(), + text("notification_type").unwrap_or("").into(), + ); + "pane.report_event" + } else { + let session = text("session_id") + .or_else(|| text("sessionId")) + .or_else(|| text("id"))?; + params.insert("session_id".into(), session.into()); + "pane.report_session" + }; + if let Some(pane) = pane.filter(|pane| !pane.is_empty()) { + params.insert("pane".into(), pane.into()); + } + Some((method, Value::Object(params))) +} + +fn run_hook() -> i32 { + if !environment_matches(|name| std::env::var_os(name)) { + return 0; + } + let mut input = Vec::new(); + if io::stdin() + .lock() + .take(MAX_HOOK_PAYLOAD + 1) + .read_to_end(&mut input) + .is_err() + { + return 0; + } + let pane = std::env::var("LUVUS_PANE_ID").ok(); + if let Some((method, params)) = hook_request(&input, pane.as_deref()) { + let _ = crate::cli::send_request(method, params); + } + 0 +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use serde_json::json; + + use super::*; + + #[test] + fn hook_reports_sessions_and_lifecycle_events_like_the_shell_hook() { + assert_eq!( + hook_request( + br#"{"hook_event_name":"SessionStart","session_id":"abc-123"}"#, + Some("7") + ), + Some(( + "pane.report_session", + json!({"agent": "claude", "session_id": "abc-123", "pane": "7"}) + )) + ); + assert_eq!( + hook_request(br#"{"event":"SessionStart","sessionId":"abc-123"}"#, None), + Some(( + "pane.report_session", + json!({"agent": "claude", "session_id": "abc-123"}) + )) + ); + assert_eq!( + hook_request( + br#"{"hook_event_name":"Notification","notification_type":"idle_prompt","message":"Claude needs your permission to use \"Bash\""}"#, + Some("7") + ), + Some(( + "pane.report_event", + json!({ + "agent": "claude", + "kind": "Notification", + "message": "Claude needs your permission to use \"Bash\"", + "notification_type": "idle_prompt", + "pane": "7", + }) + )) + ); + let long = "é".repeat(MAX_MESSAGE_CHARS + 50); + let (_, params) = hook_request( + json!({"hook_event_name": "Stop", "message": long}) + .to_string() + .as_bytes(), + None, + ) + .unwrap(); + assert_eq!( + params["message"].as_str().unwrap().chars().count(), + MAX_MESSAGE_CHARS + ); + assert_eq!(params["notification_type"], ""); + + for rejected in [ + &br#"{"hook_event_name":"SessionStart"}"#[..], + br#"{"hook_event_name":"SessionStart","session_id":""}"#, + br#"not json"#, + ] { + assert_eq!(hook_request(rejected, None), None); + } + let oversized = vec![b' '; MAX_HOOK_PAYLOAD as usize + 1]; + assert_eq!(hook_request(&oversized, None), None); + } + + #[test] + fn hook_runs_only_in_a_luvus_pane_and_never_for_devin() { + let env = |pairs: &[(&str, &str)]| { + let map: HashMap = pairs + .iter() + .map(|(key, value)| (key.to_string(), OsString::from(value))) + .collect(); + move |name: &str| map.get(name).cloned() + }; + let pane = [("LUVUS_ENV", "1"), ("LUVUS_SOCKET_PATH", "/tmp/luvus.sock")]; + assert!(environment_matches(env(&pane))); + assert!(environment_matches(env(&[ + pane[0], + pane[1], + ("DEVIN_PROJECT_DIR", "") + ]))); + assert!(!environment_matches(env(&[ + pane[0], + pane[1], + ("DEVIN_PROJECT_DIR", "/work") + ]))); + assert!(!environment_matches(env(&[pane[1]]))); + assert!(!environment_matches(env(&[ + pane[0], + ("LUVUS_SOCKET_PATH", "") + ]))); + } + + #[test] + fn windows_script_hands_the_payload_to_luvus_without_python() { + assert!(POWERSHELL_SCRIPT.contains("integration hook claude")); + assert!(POWERSHELL_SCRIPT.contains("DEVIN_PROJECT_DIR")); + assert!(POWERSHELL_SCRIPT + .lines() + .filter(|line| !line.trim_start().starts_with('#')) + .all(|line| !line.contains("python"))); + } } diff --git a/src/agent/codex/hook.ps1 b/src/agent/codex/hook.ps1 new file mode 100644 index 000000000..76e80ab37 --- /dev/null +++ b/src/agent/codex/hook.ps1 @@ -0,0 +1,19 @@ +# Luvus Codex integration for Windows. Codex passes one SessionStart or +# UserPromptSubmit JSON payload on stdin, and the Luvus binary reports the +# session to the pane running it (`luvus integration hook codex`). +# +# The binary path is fixed when the integration is installed. Codex can run +# hooks from its shared background server, which keeps the environment of +# whichever pane first started it, so an inherited LUVUS_BIN_PATH may name an +# unrelated build. +$luvus = __LUVUS_BIN__ +if (-not (Test-Path -LiteralPath $luvus -PathType Leaf)) { + $luvus = "luvus" +} +try { + & $luvus integration hook codex *> $null +} catch { + # Hooks are optional telemetry into the local Luvus server. Never interrupt + # Codex when the server or installed binary is unavailable. +} +exit 0 diff --git a/src/agent/codex/integration.rs b/src/agent/codex/integration.rs index e73511b7f..791e51e22 100644 --- a/src/agent/codex/integration.rs +++ b/src/agent/codex/integration.rs @@ -1,3 +1,4 @@ +#[cfg(not(windows))] use std::fs; use std::io::{self, Read}; use std::path::{Path, PathBuf}; @@ -16,7 +17,10 @@ pub(super) const OPERATIONS: IntegrationOperations = IntegrationOperations { hook: Some(run_hook), }; +#[cfg(any(not(windows), test))] const SCRIPT: &str = include_str!("hook.sh"); +#[cfg(any(windows, test))] +const POWERSHELL_SCRIPT: &str = include_str!("hook.ps1"); /// A submitted prompt rides in the UserPromptSubmit payload, so this bound is /// wider than for session-only hooks. A larger payload is ignored. const MAX_HOOK_PAYLOAD: u64 = 256 * 1024; @@ -24,6 +28,8 @@ const MAX_HOOK_PAYLOAD: u64 = 256 * 1024; /// when the hook runs. The total stays well inside the hook timeout. const EVIDENCE_ATTEMPTS: usize = 4; const EVIDENCE_RETRY: Duration = Duration::from_millis(400); +/// The UserPromptSubmit hook timeout, which covers every evidence attempt. +const PROMPT_HOOK_TIMEOUT_SECS: u64 = 5; fn config_dir() -> PathBuf { std::env::var_os("CODEX_HOME") @@ -41,8 +47,10 @@ fn spec() -> ShellHookSpec { } /// The installed script, with the Luvus binary that installed it. Codex keeps -/// hook trust per `hooks.json` entry, so the entries and the script path stay -/// the same as earlier releases and only the script's content changes. +/// hook trust per `hooks.json` entry, so on Unix the entries and the script +/// path stay the same as earlier releases and only the script's content +/// changes. +#[cfg(any(not(windows), test))] fn script_for(binary: &Path) -> Result { let binary = binary .to_str() @@ -51,6 +59,17 @@ fn script_for(binary: &Path) -> Result { Ok(SCRIPT.replace("__LUVUS_BIN__", "ed)) } +/// The Windows script, with the installing binary as a PowerShell literal. +#[cfg(any(windows, test))] +fn powershell_script_for(binary: &Path) -> Result { + let binary = binary + .to_str() + .ok_or_else(|| anyhow!("Luvus binary path is not valid Unicode"))?; + let quoted = format!("'{}'", binary.replace('\'', "''")); + Ok(POWERSHELL_SCRIPT.replace("__LUVUS_BIN__", "ed)) +} + +#[cfg(not(windows))] fn install() -> Result<()> { let dir = integration::install_shell_hook_with_spec("codex", spec())?; let config = dir.join("hooks.json"); @@ -64,7 +83,7 @@ fn install() -> Result<()> { "UserPromptSubmit", None, &script.to_string_lossy(), - Some(5), + Some(PROMPT_HOOK_TIMEOUT_SECS), ); fs::write(config, serde_json::to_string_pretty(&value)?)?; fs::write(&script, script_for(&std::env::current_exe()?)?)?; @@ -72,14 +91,33 @@ fn install() -> Result<()> { Ok(()) } +/// Codex runs Windows hook commands as `%COMSPEC% /C ""`, and +/// cmd.exe can neither keep a raw path with spaces together nor run a `.sh` +/// file, so Windows registers an encoded PowerShell command. The old raw-path +/// entries never ran, so Codex asks to trust the new ones once. +#[cfg(windows)] +fn install() -> Result<()> { + integration::install_powershell_hook( + &spec(), + &powershell_script_for(&std::env::current_exe()?)?, + &[("UserPromptSubmit", Some(PROMPT_HOOK_TIMEOUT_SECS))], + ) +} + fn uninstall() -> Result<()> { integration::uninstall_shell_hook(spec(), &["UserPromptSubmit"]) } +#[cfg(not(windows))] fn is_installed() -> bool { integration::shell_hook_installed(spec(), &["UserPromptSubmit"]) } +#[cfg(windows)] +fn is_installed() -> bool { + integration::powershell_hook_installed(&spec(), &["UserPromptSubmit"]) +} + /// What one Codex hook payload says about its session. #[derive(Debug, PartialEq)] struct HookReport { @@ -485,4 +523,18 @@ mod tests { "never trusts the inherited path" ); } + + #[test] + fn windows_script_calls_the_installing_binary_as_a_literal() { + let script = + powershell_script_for(Path::new(r"C:\Users\a $HOME b `x c'd %PATH%\luvus.exe")) + .unwrap(); + assert!(script.contains(r"$luvus = 'C:\Users\a $HOME b `x c''d %PATH%\luvus.exe'")); + assert!(script.contains("integration hook codex")); + assert!(!script.contains("__LUVUS_BIN__")); + assert!( + !script.contains("$env:LUVUS_BIN_PATH"), + "never trusts the inherited path" + ); + } } diff --git a/src/agent/devin/integration.rs b/src/agent/devin/integration.rs index eda7faaf3..27f423f7e 100644 --- a/src/agent/devin/integration.rs +++ b/src/agent/devin/integration.rs @@ -7,7 +7,9 @@ use anyhow::{anyhow, Result}; use serde_json::{json, Value}; use super::super::types::IntegrationOperations; -use crate::integration; +#[cfg(any(windows, test))] +use crate::integration::powershell_hook_command; +use crate::integration::{self, decode_base64}; pub(super) const OPERATIONS: IntegrationOperations = IntegrationOperations { install, @@ -75,21 +77,6 @@ fn hook_command(path: &Path) -> Result { Ok(powershell_hook_command(path)) } -/// Devin runs hook commands through the user's shell (Git Bash, cmd, or -/// PowerShell), and each one rewrites a quoted path that contains `$`, -/// backticks, `%`, or apostrophes. The script path therefore travels as a -/// single-quoted PowerShell literal inside `-EncodedCommand`, whose base64 text -/// no shell can alter. -#[cfg(any(windows, test))] -fn powershell_hook_command(path: &str) -> String { - let literal = format!("& '{}'", path.replace('\'', "''")); - let utf16: Vec = literal.encode_utf16().flat_map(u16::to_le_bytes).collect(); - format!( - "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {}", - crate::base64_encode(&utf16) - ) -} - /// Devin's hook parser rejects the whole file for one unknown event, so the /// managed group uses only fields Devin already accepts from other tools. fn managed_group(command: &str) -> Value { @@ -102,33 +89,6 @@ fn managed_group(command: &str) -> Value { }) } -/// Decodes one standard base64 token, or returns `None` when it is not one. -fn decode_base64(token: &str) -> Option> { - if token.is_empty() || !token.len().is_multiple_of(4) { - return None; - } - let (mut bits, mut count) = (0u32, 0u32); - let mut output = Vec::with_capacity(token.len() / 4 * 3); - for byte in token.trim_end_matches('=').bytes() { - let value = match byte { - b'A'..=b'Z' => byte - b'A', - b'a'..=b'z' => byte - b'a' + 26, - b'0'..=b'9' => byte - b'0' + 52, - b'+' => 62, - b'/' => 63, - _ => return None, - }; - bits = (bits << 6) | u32::from(value); - count += 6; - if count >= 8 { - count -= 8; - output.push((bits >> count) as u8); - bits &= (1 << count) - 1; - } - } - Some(output) -} - /// Whether `text` names the Luvus script. Windows file names are /// case-insensitive, so any casing there still runs the same script. fn names_script(text: &str) -> bool { diff --git a/src/integration.rs b/src/integration.rs index f869f4f39..e63b0d4ed 100644 --- a/src/integration.rs +++ b/src/integration.rs @@ -208,6 +208,139 @@ pub(crate) fn install_shell_hook_with_spec(agent: &str, spec: ShellHookSpec) -> Ok(spec.dir) } +/// The script a Windows PowerShell hook runs, next to the agent's config. +pub(crate) const POWERSHELL_HOOK_SCRIPT: &str = "luvus-agent-hook.ps1"; + +/// Agents run hook commands through a shell (Git Bash, cmd, or PowerShell), +/// and each one rewrites a raw or quoted Windows path that contains spaces, +/// backslashes, `$`, backticks, `%`, or apostrophes. The script path therefore +/// travels as a single-quoted PowerShell literal inside `-EncodedCommand`, +/// whose base64 text no shell can alter. +#[cfg(any(windows, test))] +pub(crate) fn powershell_hook_command(path: &str) -> String { + let literal = format!("& '{}'", path.replace('\'', "''")); + let utf16: Vec = literal.encode_utf16().flat_map(u16::to_le_bytes).collect(); + format!( + "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {}", + crate::base64_encode(&utf16) + ) +} + +/// Decodes one standard base64 token, or returns `None` when it is not one. +pub(crate) fn decode_base64(token: &str) -> Option> { + if token.is_empty() || !token.len().is_multiple_of(4) { + return None; + } + let (mut bits, mut count) = (0u32, 0u32); + let mut output = Vec::with_capacity(token.len() / 4 * 3); + for byte in token.trim_end_matches('=').bytes() { + let value = match byte { + b'A'..=b'Z' => byte - b'A', + b'a'..=b'z' => byte - b'a' + 26, + b'0'..=b'9' => byte - b'0' + 52, + b'+' => 62, + b'/' => 63, + _ => return None, + }; + bits = (bits << 6) | u32::from(value); + count += 6; + if count >= 8 { + count -= 8; + output.push((bits >> count) as u8); + bits &= (1 << count) - 1; + } + } + Some(output) +} + +/// The UTF-16LE text of each base64 token in a command, such as the script a +/// PowerShell `-EncodedCommand` runs. +pub(crate) fn encoded_command_texts(command: &str) -> impl Iterator + '_ { + command.split_whitespace().filter_map(|token| { + let bytes = decode_base64(token)?; + let (pairs, remainder) = bytes.as_chunks::<2>(); + let units: Vec = pairs.iter().map(|pair| u16::from_le_bytes(*pair)).collect(); + remainder + .is_empty() + .then(|| String::from_utf16(&units).ok()) + .flatten() + }) +} + +/// Windows install for an agent whose hook command runs through a shell: +/// writes `script` as [`POWERSHELL_HOOK_SCRIPT`] and registers the encoded +/// PowerShell command under `spec.event` and each `(event, timeout)` in +/// `extra_events`. Earlier Luvus entries, including the raw `.sh` path that +/// no Windows shell could run, are replaced, and the old script is removed. +#[cfg(any(windows, test))] +pub(crate) fn install_powershell_hook( + spec: &ShellHookSpec, + script: &str, + extra_events: &[(&str, Option)], +) -> Result<()> { + let cfg_path = spec.dir.join(spec.file); + let mut cfg: Value = match fs::read_to_string(&cfg_path) { + Ok(contents) => serde_json::from_str(&contents)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => json!({}), + Err(error) => return Err(error.into()), + }; + let command = powershell_hook_command_in(&spec.dir)?; + register_hook(&mut cfg, spec.event, spec.matcher, &command, None); + for (event, timeout_seconds) in extra_events { + register_hook(&mut cfg, event, None, &command, *timeout_seconds); + } + fs::create_dir_all(&spec.dir)?; + // Windows PowerShell 5.1 reads a script without a BOM in the ANSI code + // page, which would garble a non-ASCII path the script embeds. + fs::write( + spec.dir.join(POWERSHELL_HOOK_SCRIPT), + format!("\u{feff}{script}"), + )?; + fs::write(&cfg_path, serde_json::to_string_pretty(&cfg)?)?; + let _ = fs::remove_file(spec.dir.join("luvus-agent-hook.sh")); + let _ = fs::remove_file(spec.dir.join("bohay-agent-hook.sh")); + Ok(()) +} + +/// Whether the PowerShell hook is installed: the script exists and every +/// event runs exactly the command this install writes. An older raw-path +/// entry does not count, so re-enabling the integration replaces it. +#[cfg(any(windows, test))] +pub(crate) fn powershell_hook_installed(spec: &ShellHookSpec, extra_events: &[&str]) -> bool { + let Ok(command) = powershell_hook_command_in(&spec.dir) else { + return false; + }; + if !spec.dir.join(POWERSHELL_HOOK_SCRIPT).is_file() { + return false; + } + let Ok(contents) = fs::read_to_string(spec.dir.join(spec.file)) else { + return false; + }; + let Ok(value) = serde_json::from_str::(&contents) else { + return false; + }; + std::iter::once(spec.event) + .chain(extra_events.iter().copied()) + .all(|event| { + value["hooks"][event] + .as_array() + .into_iter() + .flatten() + .filter_map(|group| group["hooks"].as_array()) + .flatten() + .any(|hook| hook["command"].as_str() == Some(command.as_str())) + }) +} + +#[cfg(any(windows, test))] +fn powershell_hook_command_in(dir: &Path) -> Result { + let script = dir.join(POWERSHELL_HOOK_SCRIPT); + let path = script + .to_str() + .ok_or_else(|| anyhow!("integration path is not valid Unicode"))?; + Ok(powershell_hook_command(path)) +} + pub fn agent_ids() -> impl ExactSizeIterator + DoubleEndedIterator + Clone { crate::agent::registry::integrations() .iter() @@ -257,6 +390,7 @@ pub fn is_installed(agent: &str) -> bool { pub(crate) fn uninstall_shell_hook(spec: ShellHookSpec, extra_events: &[&str]) -> Result<()> { let _ = fs::remove_file(spec.dir.join("luvus-agent-hook.sh")); let _ = fs::remove_file(spec.dir.join("bohay-agent-hook.sh")); + let _ = fs::remove_file(spec.dir.join(POWERSHELL_HOOK_SCRIPT)); let cfg_path = spec.dir.join(spec.file); if let Ok(contents) = fs::read_to_string(&cfg_path) { if let Ok(mut value) = serde_json::from_str::(&contents) { @@ -346,13 +480,21 @@ pub(crate) fn group_mentions_luvus(group: &Value) -> bool { hs.iter().any(|h| { h.get("command") .and_then(|c| c.as_str()) - .map(|c| c.contains("luvus-agent-hook") || c.contains("bohay-agent-hook")) + .map(command_mentions_luvus) .unwrap_or(false) }) }) .unwrap_or(false) } +/// Whether a hook command runs a Luvus script, directly or, on Windows, +/// through the script path inside a PowerShell `-EncodedCommand`. +fn command_mentions_luvus(command: &str) -> bool { + let names_script = + |text: &str| text.contains("luvus-agent-hook") || text.contains("bohay-agent-hook"); + names_script(command) || encoded_command_texts(command).any(|text| names_script(&text)) +} + #[cfg(unix)] pub(crate) fn set_executable(path: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt; @@ -371,6 +513,13 @@ pub(crate) fn set_executable(_path: &Path) -> Result<()> { mod tests { use super::*; + /// The script Claude and Codex install on this platform. + const CLAUDE_SCRIPT: &str = if cfg!(windows) { + POWERSHELL_HOOK_SCRIPT + } else { + "luvus-agent-hook.sh" + }; + fn kimi_entry_is_luvus(table: &toml_edit::Table) -> bool { table .get("command") @@ -401,7 +550,7 @@ mod tests { .is_some()); assert!(operation("claude") .and_then(|operations| operations.hook) - .is_none()); + .is_some()); } #[test] @@ -497,15 +646,38 @@ mod tests { install("claude").unwrap(); install("claude").unwrap(); // idempotent - let script = tmp.join("luvus-agent-hook.sh"); + let script = tmp.join(CLAUDE_SCRIPT); assert!(script.exists()); let body = fs::read_to_string(&script).unwrap(); // Devin imports Claude-format hooks and marks its hook processes; the // script must bail on that marker and otherwise still report as claude. assert!(body.contains("DEVIN_PROJECT_DIR"), "devin guard"); - assert!(body.contains("--agent claude"), "reports as claude"); + if cfg!(windows) { + assert!( + body.contains("integration hook claude"), + "reports as claude" + ); + } else { + assert!(body.contains("--agent claude"), "reports as claude"); + } let settings: Value = serde_json::from_str(&fs::read_to_string(tmp.join("settings.json")).unwrap()).unwrap(); + // The exact command each platform's hook shell runs. + let expected = if cfg!(windows) { + powershell_hook_command(script.to_str().unwrap()) + } else { + script.to_string_lossy().into_owned() + }; + for event in ["SessionStart", "Notification", "Stop", "UserPromptSubmit"] { + assert!( + settings["hooks"][event] + .as_array() + .unwrap() + .iter() + .any(|group| group["hooks"][0]["command"] == expected.as_str()), + "{event} runs {expected}" + ); + } let groups = settings["hooks"]["SessionStart"].as_array().unwrap(); // Only one luvus entry despite installing twice. let count = groups.iter().filter(|g| group_mentions_luvus(g)).count(); @@ -571,6 +743,7 @@ mod tests { invalid_toml ); assert!(!claude.join("luvus-agent-hook.sh").exists()); + assert!(!claude.join(POWERSHELL_HOOK_SCRIPT).exists()); assert!(!kimi.join("luvus-agent-hook.sh").exists()); std::env::remove_var("CLAUDE_CONFIG_DIR"); @@ -664,14 +837,11 @@ mod tests { install("claude").unwrap(); assert!(is_installed("claude")); - assert!(tmp.join("luvus-agent-hook.sh").exists()); + assert!(tmp.join(CLAUDE_SCRIPT).exists()); uninstall("claude").unwrap(); assert!(!is_installed("claude"), "luvus hook removed"); - assert!( - !tmp.join("luvus-agent-hook.sh").exists(), - "luvus script removed" - ); + assert!(!tmp.join(CLAUDE_SCRIPT).exists(), "luvus script removed"); // The user's own hook + other settings survive; the file is intact. let v: Value = serde_json::from_str(&fs::read_to_string(tmp.join("settings.json")).unwrap()).unwrap(); @@ -716,6 +886,57 @@ mod tests { let _ = fs::remove_dir_all(&tmp); } + /// The raw `.sh` path an earlier release registered is the working install + /// on Unix, but it never ran on Windows. There it must show as not + /// installed, so re-enabling replaces it, and a missing script must not + /// count as installed either. + #[test] + fn codex_status_reports_a_raw_path_install_only_where_it_runs() { + let _env = crate::persist::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let tmp = std::env::temp_dir().join(format!("luvus-codex-legacy-{}", std::process::id())); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).unwrap(); + std::env::set_var("CODEX_HOME", &tmp); + let raw = tmp.join("luvus-agent-hook.sh"); + fs::write(&raw, "old managed script").unwrap(); + let group = |matcher: Option<&str>| { + let mut group = + json!({"hooks": [{"type": "command", "command": raw.to_string_lossy()}]}); + if let Some(matcher) = matcher { + group["matcher"] = json!(matcher); + } + group + }; + fs::write( + tmp.join("hooks.json"), + serde_json::to_string_pretty(&json!({"hooks": { + "SessionStart": [group(Some("startup|resume"))], + "UserPromptSubmit": [group(None)], + }})) + .unwrap(), + ) + .unwrap(); + assert_eq!(is_installed("codex"), !cfg!(windows)); + + install("codex").unwrap(); + assert!(is_installed("codex")); + let script = tmp.join(CLAUDE_SCRIPT); + if cfg!(windows) { + assert!(!raw.exists(), "the raw-path script is replaced"); + } + fs::remove_file(&script).unwrap(); + assert_eq!( + is_installed("codex"), + !cfg!(windows), + "on Windows the PowerShell script must exist" + ); + + std::env::remove_var("CODEX_HOME"); + let _ = fs::remove_dir_all(&tmp); + } + #[test] fn codex_hook_installs_start_and_prompt_session_reporting() { let _env = crate::persist::TEST_ENV_LOCK @@ -728,7 +949,7 @@ mod tests { install("codex").unwrap(); install("codex").unwrap(); // idempotent - let script = fs::read_to_string(tmp.join("luvus-agent-hook.sh")).unwrap(); + let script = fs::read_to_string(tmp.join(CLAUDE_SCRIPT)).unwrap(); assert!( script.contains("integration hook codex"), "the Codex adapter handles the payload" @@ -759,10 +980,18 @@ mod tests { "the hook calls the installing binary, not an inherited path that a \ shared Codex server may have taken from another pane" ); + let expected = if cfg!(windows) { + powershell_hook_command(tmp.join(CLAUDE_SCRIPT).to_str().unwrap()) + } else { + tmp.join(CLAUDE_SCRIPT).to_string_lossy().into_owned() + }; + assert_eq!(start_luvus[0]["hooks"][0]["command"], expected.as_str()); + assert_eq!(prompt_luvus[0]["hooks"][0]["command"], expected.as_str()); assert!(is_installed("codex")); uninstall("codex").unwrap(); assert!(!is_installed("codex")); + assert!(!tmp.join(CLAUDE_SCRIPT).exists()); let after: Value = serde_json::from_str(&fs::read_to_string(tmp.join("hooks.json")).unwrap()).unwrap(); for event in ["SessionStart", "UserPromptSubmit"] { @@ -1335,4 +1564,262 @@ console.log(JSON.stringify(calls)); ); let _ = fs::remove_dir_all(&tmp); } + + /// A home directory with a space, like `C:\Users\Jane Doe`, whose raw + /// path Git Bash and cmd.exe split into two words. + const SPACED_DIR: &str = "Jane Doe $HOME `x c'd %PATH%"; + + #[test] + fn powershell_hook_command_keeps_a_spaced_windows_path_literal() { + let command = powershell_hook_command(&format!( + r"C:\Users\{SPACED_DIR}\.claude\{POWERSHELL_HOOK_SCRIPT}" + )); + let (prefix, _) = command.rsplit_once(' ').unwrap(); + assert_eq!( + prefix, + "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand" + ); + assert!(command + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b" .-+/=".contains(&byte))); + assert_eq!( + encoded_command_texts(&command).collect::>(), + [r"& 'C:\Users\Jane Doe $HOME `x c''d %PATH%\.claude\luvus-agent-hook.ps1'"] + ); + assert!(command_mentions_luvus(&command)); + // Another tool's encoded hook, like the ones Orca installs, is not ours. + let foreign: Vec = r"& 'C:\tools\other-hook.ps1'" + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect(); + assert!(!command_mentions_luvus(&format!( + "powershell.exe -NoProfile -EncodedCommand {}", + crate::base64_encode(&foreign) + ))); + } + + /// An install from before the Windows fix registered the raw `.sh` path, + /// which never ran. Installing again replaces exactly those entries, keeps + /// every other hook and setting, and uninstall removes only Luvus's part. + #[test] + fn powershell_hook_replaces_raw_path_installs_and_uninstalls_surgically() { + let tmp = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target/test-state/integration") + .join(format!("powershell-hook-{}", std::process::id())) + .join(SPACED_DIR); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).unwrap(); + let spec = || HookSpec { + dir: tmp.clone(), + file: "settings.json", + event: "SessionStart", + matcher: Some("startup|resume"), + }; + let extra = ["Stop", "UserPromptSubmit"]; + let old_script = tmp.join("luvus-agent-hook.sh"); + fs::write(&old_script, "old managed script").unwrap(); + let raw = old_script.to_string_lossy().into_owned(); + let user = |event: &str| json!({"hooks": [{"type": "command", "command": format!("orca hook {event}")}]}); + let luvus = |command: &str| json!({"hooks": [{"type": "command", "command": command}]}); + let original = json!({ + "model": "opus", + "env": {"TOKEN": "keep"}, + "hooks": { + "SessionStart": [user("SessionStart"), luvus(&raw)], + "Stop": [luvus(&raw), user("Stop")], + "PreToolUse": [user("PreToolUse")], + }, + }); + fs::write( + tmp.join("settings.json"), + serde_json::to_string_pretty(&original).unwrap(), + ) + .unwrap(); + assert!( + !powershell_hook_installed(&spec(), &extra), + "a raw-path install is reported as not installed, so it gets replaced" + ); + + let events = [("Stop", None), ("UserPromptSubmit", Some(5))]; + install_powershell_hook(&spec(), "managed script", &events).unwrap(); + install_powershell_hook(&spec(), "managed script", &events).unwrap(); + + let command = powershell_hook_command(tmp.join(POWERSHELL_HOOK_SCRIPT).to_str().unwrap()); + assert!(powershell_hook_installed(&spec(), &extra)); + assert!( + !old_script.exists(), + "the script no shell could run is gone" + ); + assert_eq!( + fs::read(tmp.join(POWERSHELL_HOOK_SCRIPT)).unwrap(), + b"\xef\xbb\xbfmanaged script", + "the script starts with a UTF-8 BOM for Windows PowerShell 5.1" + ); + let value: Value = + serde_json::from_str(&fs::read_to_string(tmp.join("settings.json")).unwrap()).unwrap(); + assert_eq!(value["model"], "opus"); + assert_eq!(value["env"], original["env"]); + assert_eq!( + value["hooks"]["PreToolUse"], + original["hooks"]["PreToolUse"] + ); + let mut start = luvus(&command); + start["matcher"] = json!("startup|resume"); + let mut prompt = luvus(&command); + prompt["hooks"][0]["timeout"] = json!(5); + assert_eq!( + value["hooks"]["SessionStart"], + json!([user("SessionStart"), start]) + ); + assert_eq!( + value["hooks"]["Stop"], + json!([user("Stop"), luvus(&command)]) + ); + assert_eq!(value["hooks"]["UserPromptSubmit"], json!([prompt])); + + uninstall_shell_hook(spec(), &extra).unwrap(); + uninstall_shell_hook(spec(), &extra).unwrap(); + assert!(!powershell_hook_installed(&spec(), &extra)); + assert!(!tmp.join(POWERSHELL_HOOK_SCRIPT).exists()); + let value: Value = + serde_json::from_str(&fs::read_to_string(tmp.join("settings.json")).unwrap()).unwrap(); + assert_eq!(value["model"], "opus"); + assert_eq!(value["env"], original["env"]); + assert_eq!( + value["hooks"]["SessionStart"], + json!([user("SessionStart")]) + ); + assert_eq!(value["hooks"]["Stop"], json!([user("Stop")])); + assert_eq!(value["hooks"]["UserPromptSubmit"], json!([])); + assert_eq!( + value["hooks"]["PreToolUse"], + original["hooks"]["PreToolUse"] + ); + + let _ = fs::remove_dir_all(tmp.parent().unwrap()); + } + + #[test] + fn powershell_hook_refuses_a_malformed_config_unchanged() { + let tmp = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target/test-state/integration") + .join(format!("powershell-malformed-{}", std::process::id())); + let _ = fs::remove_dir_all(&tmp); + fs::create_dir_all(&tmp).unwrap(); + fs::write(tmp.join("hooks.json"), "{ user config").unwrap(); + let spec = HookSpec { + dir: tmp.clone(), + file: "hooks.json", + event: "SessionStart", + matcher: None, + }; + assert!(install_powershell_hook(&spec, "managed script", &[]).is_err()); + assert_eq!( + fs::read_to_string(tmp.join("hooks.json")).unwrap(), + "{ user config" + ); + assert!(!tmp.join(POWERSHELL_HOOK_SCRIPT).exists()); + let _ = fs::remove_dir_all(tmp); + } + + /// Runs the installed Windows command for real, the way each agent does: + /// Claude Code through Git Bash, Codex as `cmd.exe /C`. The script sits + /// under a directory with a space and shell metacharacters, and must hand + /// the hook payload on stdin to the native program it starts, as the real + /// script does with `luvus integration hook`. + #[cfg(windows)] + #[test] + fn windows_hook_command_runs_under_git_bash_and_cmd_with_a_spaced_home() { + use std::process::{Command, Stdio}; + + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target/test-state/integration") + .join(format!("windows-shells-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let directory = root.join(SPACED_DIR).join(".claude"); + fs::create_dir_all(&directory).unwrap(); + let received = root.join("received.txt"); + let sort = + PathBuf::from(std::env::var_os("SystemRoot").unwrap()).join(r"System32\sort.exe"); + let script = directory.join(POWERSHELL_HOOK_SCRIPT); + let literal = |path: &Path| format!("'{}'", path.display().to_string().replace('\'', "''")); + fs::write( + &script, + format!("& {} /O {}\nexit 0\n", literal(&sort), literal(&received)), + ) + .unwrap(); + let command = powershell_hook_command(script.to_str().unwrap()); + let payload = r#"{"hook_event_name":"SessionStart","session_id":"quote-check"}"#; + + let mut shells: Vec<(PathBuf, Vec<&str>)> = vec![("cmd.exe".into(), vec!["/C"])]; + let bash = std::env::var_os("ProgramFiles") + .map(|dir| PathBuf::from(dir).join(r"Git\bin\bash.exe")) + .filter(|bash| bash.is_file()); + match bash { + Some(bash) => shells.push((bash, vec!["-c"])), + None => eprintln!("Git Bash not found; checking cmd.exe only"), + } + for (shell, args) in shells { + let _ = fs::remove_file(&received); + let mut child = Command::new(&shell) + .args(&args) + .arg(&command) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(payload.as_bytes()) + .unwrap(); + assert!(child.wait().unwrap().success(), "{}", shell.display()); + assert_eq!( + fs::read_to_string(&received).unwrap_or_default().trim_end(), + payload, + "{}", + shell.display() + ); + } + let _ = fs::remove_dir_all(root); + } + + /// The Codex script embeds the installing binary's path. Windows + /// PowerShell 5.1 reads a script without a BOM in the ANSI code page, so + /// a path such as `C:\Users\José\…` must survive the installed script. + #[cfg(windows)] + #[test] + fn windows_hook_script_keeps_a_non_ascii_path_literal() { + use std::process::Command; + + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target/test-state/integration") + .join(format!("windows-non-ascii-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let dir = root.join("José Ñandú").join(".codex"); + fs::create_dir_all(&dir).unwrap(); + let received = dir.join("received.txt"); + let spec = HookSpec { + dir: dir.clone(), + file: "hooks.json", + event: "SessionStart", + matcher: None, + }; + let script = format!( + "[IO.File]::WriteAllText('{}', 'ok')\nexit 0\n", + received.display().to_string().replace('\'', "''") + ); + install_powershell_hook(&spec, &script, &[]).unwrap(); + let command = powershell_hook_command(dir.join(POWERSHELL_HOOK_SCRIPT).to_str().unwrap()); + + let status = Command::new("cmd.exe") + .args(["/C", &command]) + .status() + .unwrap(); + assert!(status.success()); + assert_eq!(fs::read_to_string(&received).unwrap(), "ok"); + let _ = fs::remove_dir_all(root); + } } diff --git a/website/src/content/docs/docs/guides/agents.mdx b/website/src/content/docs/docs/guides/agents.mdx index 3704f4e2a..76dee1035 100644 --- a/website/src/content/docs/docs/guides/agents.mdx +++ b/website/src/content/docs/docs/guides/agents.mdx @@ -279,7 +279,10 @@ luvus integration install claude # or: copilot · codex · antigravity · let or toggle it in **Settings → Integrations**. What it does per agent: - **claude / copilot / codex**: registers a small session-start hook script in - the agent's own settings file. + the agent's own settings file. On Windows, Claude Code and Codex run a + PowerShell script through an encoded command, so a home path with spaces + works under Git Bash and `cmd.exe` and no `python3` is needed. Re-enable an + integration installed by an earlier release to replace its old hook entries. - **antigravity**: adds one Luvus-owned `PreInvocation` entry to `~/.gemini/config/hooks.json`. It reports only the exact conversation id for `agy --conversation ` restore; screen detection continues to own agent From af3e0efb98cb7240954304d317f6b85852bb127a Mon Sep 17 00:00:00 2001 From: "Paulo R. Kraemer" Date: Fri, 16 Oct 2026 16:34:00 -0300 Subject: [PATCH 5/5] fix(integration): run the Kimi Code hook on Windows Kimi Code runs hook commands through cmd.exe on Windows, which split the raw luvus-agent-hook.sh path at spaces or handed the .sh file to its file association, so the hook never ran. On Windows, register the encoded PowerShell hook command and report the payload through `luvus integration hook kimi`, without python3. Re-enabling replaces the old raw-path entries; Unix is unchanged. Closes #510 --- src/agent/kimi/hook.ps1 | 21 + src/agent/kimi/integration.rs | 474 +++++++++++++++++- src/integration.rs | 36 +- .../src/content/docs/docs/guides/agents.mdx | 6 +- 4 files changed, 500 insertions(+), 37 deletions(-) create mode 100644 src/agent/kimi/hook.ps1 diff --git a/src/agent/kimi/hook.ps1 b/src/agent/kimi/hook.ps1 new file mode 100644 index 000000000..4b6b6e8b6 --- /dev/null +++ b/src/agent/kimi/hook.ps1 @@ -0,0 +1,21 @@ +# Luvus Kimi Code integration for Windows. Kimi Code runs each hook command +# through cmd.exe and passes one hook JSON payload on stdin; the Luvus binary +# reports the session or lifecycle event to the pane running it +# (`luvus integration hook kimi`), so the hook needs neither python3 nor a +# POSIX shell. + +if ($env:LUVUS_ENV -ne "1" -or [string]::IsNullOrWhiteSpace($env:LUVUS_SOCKET_PATH)) { + exit 0 +} + +$luvus = $env:LUVUS_BIN_PATH +if ([string]::IsNullOrWhiteSpace($luvus) -or -not (Test-Path -LiteralPath $luvus -PathType Leaf)) { + $luvus = "luvus" +} +try { + & $luvus integration hook kimi *> $null +} catch { + # Hooks are optional telemetry into the local Luvus server. Never interrupt + # Kimi Code when the server or installed binary is unavailable. +} +exit 0 diff --git a/src/agent/kimi/integration.rs b/src/agent/kimi/integration.rs index 3192b56a4..da48bc1a3 100644 --- a/src/agent/kimi/integration.rs +++ b/src/agent/kimi/integration.rs @@ -1,7 +1,12 @@ +use std::ffi::{OsStr, OsString}; use std::fs; +use std::io::{self, Read}; use std::path::PathBuf; +#[cfg(windows)] +use anyhow::anyhow; use anyhow::Result; +use serde_json::{Map, Value}; use toml_edit::{value, ArrayOfTables, DocumentMut, Item, Table}; use super::super::types::IntegrationOperations; @@ -11,7 +16,7 @@ pub(super) const OPERATIONS: IntegrationOperations = IntegrationOperations { install, uninstall, is_installed, - hook: None, + hook: Some(run_hook), }; const HOOK_EVENTS: &[(&str, Option<&str>)] = &[ @@ -20,6 +25,14 @@ const HOOK_EVENTS: &[(&str, Option<&str>)] = &[ ("Stop", None), ]; +#[cfg(any(windows, test))] +const POWERSHELL_SCRIPT: &str = include_str!("hook.ps1"); +/// Kimi Code's hook payloads carry no prompt, so this bound is generous. A +/// larger payload is ignored. +const MAX_HOOK_PAYLOAD: u64 = 256 * 1024; +/// The longest lifecycle message forwarded, in characters. +const MAX_MESSAGE_CHARS: usize = 200; + fn config_dir() -> PathBuf { std::env::var_os("KIMI_CODE_HOME") .map(PathBuf::from) @@ -34,8 +47,16 @@ pub(in crate::agent) fn entry_is_luvus(table: &Table) -> bool { table .get("command") .and_then(|value| value.as_str()) - .map(|command| command.contains("luvus-agent-hook") || command.contains("bohay-agent-hook")) - .unwrap_or(false) + .is_some_and(command_mentions_luvus) +} + +/// Whether a hook command runs a Luvus script: the raw `.sh` path, or on +/// Windows the script path inside a PowerShell `-EncodedCommand`. +fn command_mentions_luvus(command: &str) -> bool { + let names_script = + |text: &str| text.contains("luvus-agent-hook") || text.contains("bohay-agent-hook"); + names_script(command) + || integration::encoded_command_texts(command).any(|text| names_script(&text)) } fn strip_luvus(hooks: &mut ArrayOfTables) { @@ -50,19 +71,19 @@ fn strip_luvus(hooks: &mut ArrayOfTables) { } } -fn install() -> Result<()> { - let dir = config_dir(); - fs::create_dir_all(&dir)?; - let path = config_path(); - let mut document: DocumentMut = match fs::read_to_string(&path) { - Ok(contents) => contents.parse()?, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => DocumentMut::default(), - Err(error) => return Err(error.into()), - }; - let script = dir.join("luvus-agent-hook.sh"); - fs::write(&script, integration::agent_hook_script("kimi"))?; - integration::set_executable(&script)?; - let command = script.to_string_lossy().into_owned(); +/// The user's config, or an empty one. A malformed file is an error, so it is +/// never overwritten. +fn read_config() -> Result { + match fs::read_to_string(config_path()) { + Ok(contents) => Ok(contents.parse()?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(DocumentMut::default()), + Err(error) => Err(error.into()), + } +} + +/// Replaces every earlier Luvus entry with one per hook event that runs +/// `command`, keeping the user's own hooks. +fn register(document: &mut DocumentMut, command: &str) { let hooks = document .as_table_mut() .entry("hooks") @@ -78,10 +99,45 @@ fn install() -> Result<()> { if let Some(matcher) = matcher { table["matcher"] = value(*matcher); } - table["command"] = value(command.clone()); + table["command"] = value(command); hooks.push(table); } - fs::write(path, document.to_string())?; +} + +#[cfg(not(windows))] +fn install() -> Result<()> { + let dir = config_dir(); + fs::create_dir_all(&dir)?; + let mut document = read_config()?; + let script = dir.join("luvus-agent-hook.sh"); + fs::write(&script, integration::agent_hook_script("kimi"))?; + integration::set_executable(&script)?; + register(&mut document, &script.to_string_lossy()); + fs::write(config_path(), document.to_string())?; + let _ = fs::remove_file(dir.join("bohay-agent-hook.sh")); + Ok(()) +} + +/// Kimi Code runs hook commands with Node's `spawn(command, {shell: true})`, +/// which is cmd.exe on Windows. cmd splits a raw path at its spaces and hands +/// a `.sh` file to its file association, so the shell script never ran there. +/// Windows registers an encoded PowerShell command and a script that needs no +/// python3, and re-enabling replaces the old raw-path entries. +#[cfg(windows)] +fn install() -> Result<()> { + let dir = config_dir(); + let mut document = read_config()?; + let script = dir.join(integration::POWERSHELL_HOOK_SCRIPT); + let command = integration::powershell_hook_command( + script + .to_str() + .ok_or_else(|| anyhow!("integration path is not valid Unicode"))?, + ); + register(&mut document, &command); + fs::create_dir_all(&dir)?; + fs::write(&script, POWERSHELL_SCRIPT)?; + fs::write(config_path(), document.to_string())?; + let _ = fs::remove_file(dir.join("luvus-agent-hook.sh")); let _ = fs::remove_file(dir.join("bohay-agent-hook.sh")); Ok(()) } @@ -90,6 +146,7 @@ fn uninstall() -> Result<()> { let dir = config_dir(); let _ = fs::remove_file(dir.join("luvus-agent-hook.sh")); let _ = fs::remove_file(dir.join("bohay-agent-hook.sh")); + let _ = fs::remove_file(dir.join(integration::POWERSHELL_HOOK_SCRIPT)); let path = config_path(); if let Ok(contents) = fs::read_to_string(&path) { if let Ok(mut document) = contents.parse::() { @@ -106,15 +163,378 @@ fn uninstall() -> Result<()> { Ok(()) } +fn installed_hooks() -> Option { + let document = fs::read_to_string(config_path()) + .ok()? + .parse::() + .ok()?; + document.get("hooks")?.as_array_of_tables().cloned() +} + +#[cfg(not(windows))] fn is_installed() -> bool { - fs::read_to_string(config_path()) - .ok() - .and_then(|contents| contents.parse::().ok()) - .and_then(|document| { - document - .get("hooks") - .and_then(Item::as_array_of_tables) - .map(|hooks| hooks.iter().any(entry_is_luvus)) + installed_hooks().is_some_and(|hooks| hooks.iter().any(entry_is_luvus)) +} + +/// Installed only when the script exists and every event runs exactly the +/// command this install writes. An older raw-path entry, which cmd.exe never +/// ran, does not count, so re-enabling the integration replaces it. +#[cfg(windows)] +fn is_installed() -> bool { + let script = config_dir().join(integration::POWERSHELL_HOOK_SCRIPT); + let Some(path) = script.to_str() else { + return false; + }; + let command = integration::powershell_hook_command(path); + script.is_file() + && installed_hooks().is_some_and(|hooks| { + HOOK_EVENTS.iter().all(|(event, _)| { + hooks.iter().any(|table| { + table.get("event").and_then(Item::as_str) == Some(*event) + && table.get("command").and_then(Item::as_str) == Some(command.as_str()) + }) + }) }) - .unwrap_or(false) +} + +/// The hook runs only inside a Luvus pane. +fn environment_matches(var: impl Fn(&str) -> Option) -> bool { + var("LUVUS_ENV").as_deref() == Some(OsStr::new("1")) + && var("LUVUS_SOCKET_PATH").is_some_and(|value| !value.is_empty()) +} + +/// The request one Kimi Code hook payload makes, matching the shell hook: a +/// lifecycle event becomes `pane.report_event`, and any other event with a +/// session id becomes `pane.report_session`. Kimi's payload is snake_case: +/// `hook_event_name`, `session_id`. +fn hook_request(input: &[u8], pane: Option<&str>) -> Option<(&'static str, Value)> { + if input.len() as u64 > MAX_HOOK_PAYLOAD { + return None; + } + let payload: Value = serde_json::from_slice(input).ok()?; + let text = |key: &str| { + payload + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + }; + let mut params = Map::new(); + params.insert("agent".into(), super::DESCRIPTOR.id.into()); + let event = text("hook_event_name").unwrap_or(""); + let method = if matches!(event, "Notification" | "Stop") { + let message: String = text("message") + .unwrap_or("") + .chars() + .take(MAX_MESSAGE_CHARS) + .collect(); + params.insert("kind".into(), event.into()); + params.insert("message".into(), message.into()); + params.insert( + "notification_type".into(), + text("notification_type").unwrap_or("").into(), + ); + "pane.report_event" + } else { + params.insert("session_id".into(), text("session_id")?.into()); + "pane.report_session" + }; + if let Some(pane) = pane.filter(|pane| !pane.is_empty()) { + params.insert("pane".into(), pane.into()); + } + Some((method, Value::Object(params))) +} + +/// `luvus integration hook kimi`, run by the Windows script: reads one hook +/// payload on stdin and reports it to the pane running Kimi Code. +fn run_hook() -> i32 { + if !environment_matches(|name| std::env::var_os(name)) { + return 0; + } + let mut input = Vec::new(); + if io::stdin() + .lock() + .take(MAX_HOOK_PAYLOAD + 1) + .read_to_end(&mut input) + .is_err() + { + return 0; + } + let pane = std::env::var("LUVUS_PANE_ID").ok(); + if let Some((method, params)) = hook_request(&input, pane.as_deref()) { + let _ = crate::cli::send_request(method, params); + } + 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn windows_script_hands_the_payload_to_luvus_without_python() { + assert!(POWERSHELL_SCRIPT.contains("integration hook kimi")); + assert!(POWERSHELL_SCRIPT + .lines() + .filter(|line| !line.trim_start().starts_with('#')) + .all(|line| !line.contains("python"))); + } + + #[test] + fn luvus_entries_are_recognised_raw_and_encoded() { + let entry = |command: &str| { + let mut table = Table::new(); + table["command"] = value(command); + table + }; + assert!(entry_is_luvus(&entry( + r"C:\Users\Jane Doe\.kimi-code\luvus-agent-hook.sh" + ))); + assert!(entry_is_luvus(&entry( + "/home/jane/.kimi-code/bohay-agent-hook.sh" + ))); + assert!(entry_is_luvus(&entry( + &integration::powershell_hook_command( + r"C:\Users\Jane Doe\.kimi-code\luvus-agent-hook.ps1" + ) + ))); + assert!(!entry_is_luvus(&entry("echo mine"))); + assert!(!entry_is_luvus(&Table::new())); + } + + /// A home directory with a space and shell metacharacters, like a user + /// profile under `C:\Users`, whose raw path cmd.exe splits. + #[cfg(windows)] + const SPACED_DIR: &str = "Jane Doe $HOME `x c'd %PATH%"; + + #[test] + fn foreign_encoded_hooks_are_not_ours() { + let foreign: Vec = r"& 'C:\tools\other-hook.ps1'" + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect(); + assert!(!command_mentions_luvus(&format!( + "powershell.exe -NoProfile -EncodedCommand {}", + crate::base64_encode(&foreign) + ))); + } + + /// The SessionStart payload Kimi Code 2.2.0 sends on Windows. + #[test] + fn hook_reports_kimi_sessions_and_lifecycle_events() { + let start = br#"{"hook_event_name":"SessionStart","session_id":"session_b9f339b9-dabb-48f0-bacf-79fe37c3e8a0","cwd":"C:/work","client_type":"kimi_code_cli","source":"startup","model":"__kimi_env_model__","profile":"agent"}"#; + assert_eq!( + hook_request(start, Some("2")), + Some(( + "pane.report_session", + serde_json::json!({ + "agent": "kimi", + "session_id": "session_b9f339b9-dabb-48f0-bacf-79fe37c3e8a0", + "pane": "2", + }) + )) + ); + assert_eq!( + hook_request( + br#"{"hook_event_name":"Notification","notification_type":"permission","message":"Approve?"}"#, + None + ), + Some(( + "pane.report_event", + serde_json::json!({ + "agent": "kimi", + "kind": "Notification", + "message": "Approve?", + "notification_type": "permission", + }) + )) + ); + let long = "é".repeat(MAX_MESSAGE_CHARS + 50); + let (_, params) = hook_request( + serde_json::json!({"hook_event_name": "Stop", "message": long}) + .to_string() + .as_bytes(), + None, + ) + .unwrap(); + assert_eq!( + params["message"].as_str().unwrap().chars().count(), + MAX_MESSAGE_CHARS + ); + for rejected in [ + &br#"{"hook_event_name":"SessionStart"}"#[..], + br#"{"hook_event_name":"SessionStart","session_id":""}"#, + br#"not json"#, + ] { + assert_eq!(hook_request(rejected, None), None); + } + let oversized = vec![b' '; MAX_HOOK_PAYLOAD as usize + 1]; + assert_eq!(hook_request(&oversized, None), None); + } + + #[test] + fn hook_runs_only_in_a_luvus_pane() { + let env = |pairs: &'static [(&'static str, &'static str)]| { + move |name: &str| { + pairs + .iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| OsString::from(value)) + } + }; + assert!(environment_matches(env(&[ + ("LUVUS_ENV", "1"), + ("LUVUS_SOCKET_PATH", "/tmp/luvus.sock") + ]))); + assert!(!environment_matches(env(&[( + "LUVUS_SOCKET_PATH", + "/tmp/luvus.sock" + )]))); + assert!(!environment_matches(env(&[ + ("LUVUS_ENV", "1"), + ("LUVUS_SOCKET_PATH", "") + ]))); + } + + /// An install from before the Windows fix registered the raw `.sh` path, + /// which cmd.exe never ran. Installing again under a spaced home replaces + /// exactly those entries with the encoded PowerShell command and keeps the + /// user's hooks and settings. + #[cfg(windows)] + #[test] + fn kimi_windows_install_replaces_raw_path_entries() { + let _env = crate::persist::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target/test-state/integration") + .join(format!("kimi-raw-{}", std::process::id())); + let tmp = root.join(SPACED_DIR).join(".kimi-code"); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&tmp).unwrap(); + std::env::set_var("KIMI_CODE_HOME", &tmp); + let old_script = tmp.join("luvus-agent-hook.sh"); + fs::write(&old_script, "old managed script").unwrap(); + let mut original = String::from( + "default_model = \"kimi-code/k3\"\n\n\ + [[hooks]]\nevent = \"PreToolUse\"\ncommand = \"echo mine\"\n", + ); + for event in ["SessionStart", "Notification", "Stop"] { + original.push_str(&format!( + "\n[[hooks]]\nevent = \"{event}\"\ncommand = {}\n", + toml_edit::Value::from(old_script.to_str().unwrap()) + )); + } + fs::write(tmp.join("config.toml"), &original).unwrap(); + assert!( + !is_installed(), + "a raw-path install is reported as not installed, so it gets replaced" + ); + + install().unwrap(); + install().unwrap(); + + assert!(is_installed()); + assert!( + !old_script.exists(), + "the script cmd.exe could not run is gone" + ); + assert_eq!( + fs::read_to_string(tmp.join(integration::POWERSHELL_HOOK_SCRIPT)).unwrap(), + POWERSHELL_SCRIPT + ); + let command = integration::powershell_hook_command( + tmp.join(integration::POWERSHELL_HOOK_SCRIPT) + .to_str() + .unwrap(), + ); + let after: toml_edit::DocumentMut = fs::read_to_string(tmp.join("config.toml")) + .unwrap() + .parse() + .unwrap(); + assert_eq!(after["default_model"].as_str(), Some("kimi-code/k3")); + let hooks: Vec<_> = after["hooks"] + .as_array_of_tables() + .unwrap() + .iter() + .map(|t| { + ( + t["event"].as_str().unwrap().to_string(), + t["command"].as_str().unwrap().to_string(), + ) + }) + .collect(); + let entry = |event: &str, command: &str| (event.to_string(), command.to_string()); + assert_eq!( + hooks, + [ + entry("PreToolUse", "echo mine"), + entry("SessionStart", &command), + entry("Notification", &command), + entry("Stop", &command), + ] + ); + + uninstall().unwrap(); + assert!(!is_installed()); + assert!(!tmp.join(integration::POWERSHELL_HOOK_SCRIPT).exists()); + std::env::remove_var("KIMI_CODE_HOME"); + let _ = fs::remove_dir_all(&root); + } + + /// Runs the installed Kimi command the way Kimi Code does on Windows: + /// Node's `spawn(command, {shell: true})` is `cmd.exe /d /s /c ""`. + /// The installed script must start under a spaced home and hand the hook + /// payload on stdin to the program it calls in place of `luvus`. + #[cfg(windows)] + #[test] + fn kimi_windows_hook_command_runs_under_cmd_with_a_spaced_home() { + use std::io::Write; + use std::os::windows::process::CommandExt; + use std::process::{Command, Stdio}; + + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target/test-state/integration") + .join(format!("kimi-cmd-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let directory = root.join(SPACED_DIR).join(".kimi-code"); + fs::create_dir_all(&directory).unwrap(); + let received = root.join("received.txt"); + let sort = + PathBuf::from(std::env::var_os("SystemRoot").unwrap()).join(r"System32\sort.exe"); + let literal = |path: &std::path::Path| { + format!("'{}'", path.display().to_string().replace('\'', "''")) + }; + let script = POWERSHELL_SCRIPT.replace( + "& $luvus integration hook kimi *> $null", + &format!("& {} /O {}", literal(&sort), literal(&received)), + ); + assert!(script.contains("sort.exe"), "the stand-in call is in place"); + let path = directory.join(integration::POWERSHELL_HOOK_SCRIPT); + fs::write(&path, script).unwrap(); + let command = integration::powershell_hook_command(path.to_str().unwrap()); + let payload = + r#"{"hook_event_name":"SessionStart","session_id":"session_quote","source":"startup"}"#; + + let mut child = Command::new("cmd.exe") + .raw_arg(format!("/d /s /c \"{command}\"")) + .env("LUVUS_ENV", "1") + .env("LUVUS_SOCKET_PATH", r"\\.\pipe\luvus-test") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(payload.as_bytes()) + .unwrap(); + assert!(child.wait().unwrap().success()); + assert_eq!( + fs::read_to_string(&received).unwrap_or_default().trim_end(), + payload + ); + let _ = fs::remove_dir_all(root); + } } diff --git a/src/integration.rs b/src/integration.rs index e63b0d4ed..e5ce803b0 100644 --- a/src/integration.rs +++ b/src/integration.rs @@ -524,10 +524,7 @@ mod tests { table .get("command") .and_then(|value| value.as_str()) - .map(|command| { - command.contains("luvus-agent-hook") || command.contains("bohay-agent-hook") - }) - .unwrap_or(false) + .is_some_and(command_mentions_luvus) } fn omp_extension() -> &'static str { @@ -1030,7 +1027,18 @@ mod tests { install("kimi").unwrap(); install("kimi").unwrap(); // idempotent assert!(is_installed("kimi")); - assert!(tmp.join("luvus-agent-hook.sh").exists()); + // Kimi Code runs hooks through cmd.exe on Windows and sh elsewhere. + let script = tmp.join(if cfg!(windows) { + POWERSHELL_HOOK_SCRIPT + } else { + "luvus-agent-hook.sh" + }); + assert!(script.exists()); + let expected = if cfg!(windows) { + powershell_hook_command(script.to_str().unwrap()) + } else { + script.to_string_lossy().into_owned() + }; let after = fs::read_to_string(tmp.join("config.toml")).unwrap(); // The secret, comment, and user's own hook all survive the edit. @@ -1042,6 +1050,15 @@ mod tests { let hooks = doc["hooks"].as_array_of_tables().unwrap(); let luvus = hooks.iter().filter(|t| kimi_entry_is_luvus(t)).count(); assert_eq!(luvus, 3, "SessionStart + Notification + Stop, no dupes"); + for event in ["SessionStart", "Notification", "Stop"] { + assert!( + hooks.iter().any(|t| { + t.get("event").and_then(|v| v.as_str()) == Some(event) + && t.get("command").and_then(|v| v.as_str()) == Some(expected.as_str()) + }), + "{event} runs {expected}" + ); + } let sess = hooks .iter() .find(|t| t.get("event").and_then(|v| v.as_str()) == Some("SessionStart")) @@ -1051,13 +1068,14 @@ mod tests { uninstall("kimi").unwrap(); assert!(!is_installed("kimi"), "luvus hooks removed"); assert!(!tmp.join("luvus-agent-hook.sh").exists()); + assert!(!tmp.join(POWERSHELL_HOOK_SCRIPT).exists()); let cleaned = fs::read_to_string(tmp.join("config.toml")).unwrap(); assert!(cleaned.contains("sk-secret-123"), "secret still intact"); assert!(cleaned.contains("echo mine"), "user's hook still intact"); - assert!( - !cleaned.contains("luvus-agent-hook"), - "no luvus hooks remain" - ); + let doc: toml_edit::DocumentMut = cleaned.parse().unwrap(); + let hooks = doc["hooks"].as_array_of_tables().unwrap(); + assert_eq!(hooks.len(), 1, "only the user's hook remains"); + assert!(!hooks.iter().any(kimi_entry_is_luvus)); uninstall("kimi").unwrap(); // idempotent std::env::remove_var("KIMI_CODE_HOME"); diff --git a/website/src/content/docs/docs/guides/agents.mdx b/website/src/content/docs/docs/guides/agents.mdx index 76dee1035..efa588d4d 100644 --- a/website/src/content/docs/docs/guides/agents.mdx +++ b/website/src/content/docs/docs/guides/agents.mdx @@ -298,7 +298,11 @@ or toggle it in **Settings → Integrations**. What it does per agent: to the inherited owner-local Luvus endpoint. No `luvus` process is spawned per update and no agent database is opened. - **kimi**: adds a `[[hooks]]` entry to `~/.kimi-code/config.toml`, edited in - place so your API keys, comments, and own hooks are left untouched. + place so your API keys, comments, and own hooks are left untouched. On + Windows, Kimi Code runs hooks through `cmd.exe`, so the entry runs a + PowerShell script through an encoded command and needs no `python3`. + Re-enable an integration installed by an earlier release to replace its old + hook entries. - **grok**: installs its own hook file without editing Grok's authentication configuration. - **hermes**: installs an opt-in plugin under the active `HERMES_HOME`. It