From 3f27935b926a560f96c49b2377534e0d92b46587 Mon Sep 17 00:00:00 2001 From: Jairo Fernandez Date: Thu, 28 May 2026 23:46:25 -0500 Subject: [PATCH 1/2] feat(terminal): selectable embedded shells + resilient PTY reader Embedded PTY previously hardcoded the platform default shell (cmd.exe on Windows) and the reader thread died on any read error, freezing chatty processes like `next start` on Windows ConPTY. - Reader thread retries transient errors (Interrupted/WouldBlock), only ends on EOF/BrokenPipe. Fixes log streaming cutting out on Windows. - terminal_list_shells detects cmd/powershell/pwsh/git-bash/wsl (Windows) and zsh/bash/fish (unix); terminal_spawn takes a shell arg. - Persist preferred_shell in KubeconfigSettings; settings_set merges so one field no longer clobbers the other. Shell picker in the dock + a preference selector in kubeconfig settings. - Bump portable-pty 0.8 -> 0.9 for Windows ConPTY read fixes. --- apps/knock-app/src-tauri/Cargo.toml | 2 +- .../knock-app/src-tauri/src/kubeconfig_cmd.rs | 22 +- apps/knock-app/src-tauri/src/lib.rs | 1 + apps/knock-app/src-tauri/src/terminal_cmd.rs | 198 +++++++++++++++++- apps/knock-app/src/BottomTerminalDock.tsx | 104 +++++++-- apps/knock-app/src/KubeconfigsView.tsx | 39 +++- apps/knock-app/src/kubeTerminalStore.ts | 31 ++- apps/knock-app/src/styles.css | 48 +++++ crates/knock-core/src/kubeconfigs.rs | 11 + 9 files changed, 416 insertions(+), 40 deletions(-) diff --git a/apps/knock-app/src-tauri/Cargo.toml b/apps/knock-app/src-tauri/Cargo.toml index 929868a..c3d996f 100644 --- a/apps/knock-app/src-tauri/Cargo.toml +++ b/apps/knock-app/src-tauri/Cargo.toml @@ -27,7 +27,7 @@ toml = { workspace = true } base64 = { workspace = true } dirs = { workspace = true } rand = { workspace = true } -portable-pty = "0.8" +portable-pty = "0.9" uuid = { version = "1", features = ["v4"] } reqwest = { workspace = true } url = "2" diff --git a/apps/knock-app/src-tauri/src/kubeconfig_cmd.rs b/apps/knock-app/src-tauri/src/kubeconfig_cmd.rs index 0dc3f6b..36ec36c 100644 --- a/apps/knock-app/src-tauri/src/kubeconfig_cmd.rs +++ b/apps/knock-app/src-tauri/src/kubeconfig_cmd.rs @@ -19,6 +19,7 @@ pub struct KubeEntryDto { #[serde(rename_all = "camelCase")] pub struct KubeSettingsDto { pub preferred_terminal: String, + pub preferred_shell: String, } /// Cache of decrypted temp paths (and launcher scripts). @@ -225,15 +226,24 @@ pub fn kubeconfig_settings_get() -> Result { let s = kubeconfigs::read_settings(&dir).map_err(map_err)?; Ok(KubeSettingsDto { preferred_terminal: s.preferred_terminal, + preferred_shell: s.preferred_shell, }) } #[tauri::command] -pub fn kubeconfig_settings_set(preferred_terminal: String) -> Result<(), String> { +pub fn kubeconfig_settings_set( + preferred_terminal: Option, + preferred_shell: Option, +) -> Result<(), String> { let dir = kubeconfigs::default_store_dir().map_err(map_err)?; - kubeconfigs::write_settings( - &dir, - &kubeconfigs::KubeconfigSettings { preferred_terminal }, - ) - .map_err(map_err) + // Merge over the stored settings so a caller can update one field without + // clobbering the other. + let mut s = kubeconfigs::read_settings(&dir).map_err(map_err)?; + if let Some(t) = preferred_terminal { + s.preferred_terminal = t; + } + if let Some(sh) = preferred_shell { + s.preferred_shell = sh; + } + kubeconfigs::write_settings(&dir, &s).map_err(map_err) } diff --git a/apps/knock-app/src-tauri/src/lib.rs b/apps/knock-app/src-tauri/src/lib.rs index 8b608a0..f8daefa 100644 --- a/apps/knock-app/src-tauri/src/lib.rs +++ b/apps/knock-app/src-tauri/src/lib.rs @@ -101,6 +101,7 @@ pub fn run() { kubeconfig_cmd::kubeconfig_settings_set, terminal_cmd::kubeconfig_list_terminals, terminal_cmd::kubeconfig_open_terminal, + terminal_cmd::terminal_list_shells, terminal_cmd::terminal_spawn, terminal_cmd::terminal_write, terminal_cmd::terminal_resize, diff --git a/apps/knock-app/src-tauri/src/terminal_cmd.rs b/apps/knock-app/src-tauri/src/terminal_cmd.rs index 6d9a34a..a34028d 100644 --- a/apps/knock-app/src-tauri/src/terminal_cmd.rs +++ b/apps/knock-app/src-tauri/src/terminal_cmd.rs @@ -487,6 +487,158 @@ fn default_shell() -> String { } } +/// A shell the embedded PTY can launch. `id` is a stable key; the backend maps +/// it to a concrete program + args in `shell_command`. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ShellInfo { + pub id: String, + pub label: String, + pub available: bool, +} + +#[cfg(windows)] +fn windows_git_bash() -> Option { + // git-bash ships bash.exe under \bin\bash.exe. Probe PATH then the + // standard install locations. + if let Some(p) = which_win("bash.exe") { + return Some(p); + } + for base in [ + std::env::var("ProgramFiles").ok(), + std::env::var("ProgramFiles(x86)").ok(), + std::env::var("LocalAppData").ok(), + ] + .into_iter() + .flatten() + { + let p = PathBuf::from(base).join("Git").join("bin").join("bash.exe"); + if p.exists() { + return Some(p); + } + } + None +} + +#[cfg(windows)] +const WINDOWS_SHELLS: &[(&str, &str)] = &[ + ("cmd", "Command Prompt"), + ("powershell", "Windows PowerShell"), + ("pwsh", "PowerShell 7"), + ("git-bash", "Git Bash"), + ("wsl", "WSL"), +]; + +#[cfg(unix)] +const UNIX_SHELLS: &[(&str, &str)] = &[ + ("zsh", "zsh"), + ("bash", "bash"), + ("fish", "fish"), +]; + +fn shell_available(id: &str) -> bool { + #[cfg(windows)] + { + match id { + "cmd" => true, + "powershell" => which_win("powershell.exe").is_some(), + "pwsh" => which_win("pwsh.exe").is_some(), + "git-bash" => windows_git_bash().is_some(), + "wsl" => which_win("wsl.exe").is_some(), + _ => false, + } + } + #[cfg(unix)] + { + // Probe both common bin dirs without requiring `which`. + let candidates = [ + format!("/bin/{id}"), + format!("/usr/bin/{id}"), + format!("/usr/local/bin/{id}"), + format!("/opt/homebrew/bin/{id}"), + ]; + candidates.iter().any(|p| Path::new(p).exists()) + } +} + +#[tauri::command] +pub fn terminal_list_shells() -> Vec { + let mut out = vec![ShellInfo { + id: "auto".to_string(), + label: "Default shell".to_string(), + available: true, + }]; + #[cfg(windows)] + { + for (id, label) in WINDOWS_SHELLS { + out.push(ShellInfo { + id: (*id).to_string(), + label: (*label).to_string(), + available: shell_available(id), + }); + } + } + #[cfg(unix)] + { + for (id, label) in UNIX_SHELLS { + out.push(ShellInfo { + id: (*id).to_string(), + label: (*label).to_string(), + available: shell_available(id), + }); + } + } + out +} + +/// Build the `(program, args)` for a requested shell id. Falls back to the +/// platform default when `id` is "auto"/empty/unknown or the shell is missing. +fn shell_command(id: &str) -> (String, Vec) { + let id = id.trim(); + #[cfg(windows)] + { + match id { + "powershell" if shell_available("powershell") => ( + "powershell.exe".to_string(), + vec!["-NoLogo".to_string()], + ), + "pwsh" if shell_available("pwsh") => { + ("pwsh.exe".to_string(), vec!["-NoLogo".to_string()]) + } + "git-bash" => { + if let Some(bash) = windows_git_bash() { + return (bash.to_string_lossy().to_string(), vec!["-l".to_string()]); + } + (default_shell(), vec![]) + } + "wsl" if shell_available("wsl") => ("wsl.exe".to_string(), vec![]), + "cmd" => ( + std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string()), + vec![], + ), + _ => (default_shell(), vec![]), + } + } + #[cfg(unix)] + { + let login = vec!["-l".to_string()]; + match id { + "zsh" if shell_available("zsh") => ("zsh".to_string(), login), + "bash" if shell_available("bash") => ("bash".to_string(), login), + "fish" if shell_available("fish") => ("fish".to_string(), vec!["-l".to_string()]), + _ => { + let sh = default_shell(); + let args = if sh.ends_with("zsh") || sh.ends_with("bash") { + vec!["-l".to_string()] + } else { + vec![] + }; + (sh, args) + } + } + } +} + #[tauri::command] pub fn terminal_spawn( app: AppHandle, @@ -498,6 +650,7 @@ pub fn terminal_spawn( cwd: Option, cols: Option, rows: Option, + shell: Option, ) -> Result { let kubeconfig_path = if let Some(name) = name.filter(|s| !s.is_empty()) { let project = project_or_default(project); @@ -517,13 +670,21 @@ pub fn terminal_spawn( }) .map_err(|e| e.to_string())?; - let shell = default_shell(); - let mut cmd = CommandBuilder::new(&shell); - #[cfg(unix)] - { - if shell.ends_with("zsh") || shell.ends_with("bash") { - cmd.arg("-l"); - } + // Explicit `shell` wins; otherwise fall back to the persisted preference. + let shell_id = shell + .filter(|s| !s.is_empty() && s != "auto") + .or_else(|| { + kubeconfigs::default_store_dir() + .ok() + .and_then(|dir| kubeconfigs::read_settings(&dir).ok()) + .map(|s| s.preferred_shell) + .filter(|s| !s.is_empty() && s != "auto") + }) + .unwrap_or_else(|| "auto".to_string()); + let (program, args) = shell_command(&shell_id); + let mut cmd = CommandBuilder::new(&program); + for a in &args { + cmd.arg(a); } if let Some(kubeconfig_path) = kubeconfig_path { cmd.env("KUBECONFIG", &kubeconfig_path); @@ -567,12 +728,31 @@ pub fn terminal_spawn( let mut buf = [0u8; 8192]; loop { match reader.read(&mut buf) { - Ok(0) => break, + Ok(0) => break, // EOF: PTY closed, process tree exited Ok(n) => { let chunk = base64::engine::general_purpose::STANDARD.encode(&buf[..n]); let _ = app_for_reader.emit(&event_name, chunk); } - Err(_) => break, + // Transient errors must NOT kill the stream. On Windows ConPTY a + // chatty process (e.g. `next start` flooding logs) can surface + // Interrupted/WouldBlock/spurious errors mid-stream; breaking here + // froze the terminal. Retry on those; only a true disconnect ends it. + Err(e) => match e.kind() { + std::io::ErrorKind::Interrupted => continue, + std::io::ErrorKind::WouldBlock => { + // Reader is blocking by default; if the OS ever returns + // this, sleep to avoid a busy spin. + std::thread::sleep(std::time::Duration::from_millis(5)); + continue; + } + std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::UnexpectedEof => break, + _ => { + // Unknown error: yield briefly and retry rather than + // tearing down a live session. + std::thread::sleep(std::time::Duration::from_millis(10)); + continue; + } + }, } } let _ = app_for_reader.emit(&exit_event, ()); diff --git a/apps/knock-app/src/BottomTerminalDock.tsx b/apps/knock-app/src/BottomTerminalDock.tsx index b151a64..d7fc4a7 100644 --- a/apps/knock-app/src/BottomTerminalDock.tsx +++ b/apps/knock-app/src/BottomTerminalDock.tsx @@ -1,6 +1,12 @@ +import { useEffect, useRef, useState } from "react"; import { useColumnDrag } from "./hooks"; import { KubeTerminalsPane } from "./KubeTerminalsPane"; -import { terminalStore, type KubeTerminalSpawnArgs } from "./kubeTerminalStore"; +import { + listShells, + terminalStore, + type KubeTerminalSpawnArgs, + type ShellInfo, +} from "./kubeTerminalStore"; interface Props { spawnArgs: KubeTerminalSpawnArgs | null; @@ -48,15 +54,38 @@ export function BottomTerminalDock({ onDelta: (delta) => onHeightDelta(-delta), }); - async function openNewShell() { + const [shells, setShells] = useState([]); + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + void listShells().then(setShells); + }, []); + + useEffect(() => { + if (!menuOpen) return; + function onDocClick(ev: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(ev.target as Node)) { + setMenuOpen(false); + } + } + document.addEventListener("mousedown", onDocClick); + return () => document.removeEventListener("mousedown", onDocClick); + }, [menuOpen]); + + async function openNewShell(shell?: string) { if (spawnArgs) { - await terminalStore.openNewTab(spawnArgs); + await terminalStore.openNewTab({ ...spawnArgs, shell: shell ?? spawnArgs.shell ?? null }); } else { - await terminalStore.openGeneralTab(); + await terminalStore.openGeneralTab({ shell: shell ?? null }); } onExpandedChange(true); } + // Only offer the picker when there's more than the default + at least one + // concrete shell is detected. + const pickable = shells.filter((s) => s.id !== "auto" && s.available); + return (
onExpandedChange(true)} toolbar={
- +
+ + {pickable.length > 0 && ( + + )} + {menuOpen && pickable.length > 0 && ( +
+ {pickable.map((s) => ( + + ))} +
+ )} +
+
+ + +
diff --git a/apps/knock-app/src/kubeTerminalStore.ts b/apps/knock-app/src/kubeTerminalStore.ts index 71be5f5..7d46b69 100644 --- a/apps/knock-app/src/kubeTerminalStore.ts +++ b/apps/knock-app/src/kubeTerminalStore.ts @@ -35,6 +35,14 @@ export interface KubeTerminalSpawnArgs { passphrase: string | null; cwd?: string | null; title?: string | null; + /** Shell id (cmd/powershell/pwsh/git-bash/wsl/zsh/bash/fish), or "auto". */ + shell?: string | null; +} + +export interface ShellInfo { + id: string; + label: string; + available: boolean; } export interface TerminalEntry { @@ -43,6 +51,7 @@ export interface TerminalEntry { project: string | null; // kubeconfig project, null for a plain shell encrypted: boolean; cwd: string | null; + shell: string | null; // shell id, "auto" => backend default sessionId: string | null; // backend session id (null until spawned) term: Terminal; fit: FitAddon; @@ -147,7 +156,9 @@ class Store { return tabId; } - async openGeneralTab(args: { cwd?: string | null; title?: string | null } = {}): Promise { + async openGeneralTab( + args: { cwd?: string | null; title?: string | null; shell?: string | null } = {}, + ): Promise { return this.openNewTab({ name: null, project: null, @@ -155,6 +166,7 @@ class Store { passphrase: null, cwd: args.cwd ?? null, title: args.title ?? "Shell", + shell: args.shell ?? null, }); } @@ -174,6 +186,7 @@ class Store { passphrase: null, cwd: src.cwd, title: src.name ? src.title : "Shell", + shell: src.shell, }); } @@ -193,6 +206,7 @@ class Store { passphrase: null, // temp file is already cached after the first spawn cwd: src.cwd, title: src.name ? undefined : "Shell", + shell: src.shell, }); tab.layout = replaceLeaf(tab.layout, tab.activeLeaf, { kind: "split", @@ -267,6 +281,7 @@ class Store { passphrase: string | null; cwd?: string | null; title?: string | null; + shell?: string | null; color?: string; }): TerminalEntry { const id = `term-${cryptoRandom()}`; @@ -296,6 +311,7 @@ class Store { project: args.project ?? null, encrypted: args.encrypted ?? false, cwd: args.cwd ?? null, + shell: args.shell ?? null, sessionId: null, term, fit, @@ -389,6 +405,7 @@ class Store { cwd: entry.cwd, cols: entry.term.cols || 80, rows: entry.term.rows || 24, + shell: entry.shell ?? "auto", }); entry.sessionId = sessionId; @@ -486,6 +503,18 @@ class Store { export const terminalStore = new Store(); +/** List the shells the embedded PTY can launch on this OS. Cached after first call. */ +let shellsCache: ShellInfo[] | null = null; +export async function listShells(): Promise { + if (shellsCache) return shellsCache; + try { + shellsCache = await invoke("terminal_list_shells"); + } catch { + shellsCache = [{ id: "auto", label: "Default shell", available: true }]; + } + return shellsCache; +} + // -------- Pane tree helpers -------- export function collectLeaves(p: Pane): { kind: "leaf"; termId: string }[] { diff --git a/apps/knock-app/src/styles.css b/apps/knock-app/src/styles.css index dc96338..77ee6b5 100644 --- a/apps/knock-app/src/styles.css +++ b/apps/knock-app/src/styles.css @@ -1981,6 +1981,54 @@ button.danger:hover:not(:disabled) { gap: 6px; } +/* Split "Shell" button + shell-picker dropdown. */ +.bottom-terminal-new-group { + position: relative; + display: inline-flex; + align-items: center; + gap: 0; +} +.bottom-terminal-new-group .bottom-terminal-new { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.bottom-terminal-actions .bottom-terminal-new-caret { + width: 18px; + min-width: 18px; + border-left: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} +.bottom-terminal-shell-menu { + position: absolute; + bottom: calc(100% + 4px); + right: 0; + z-index: 30; + min-width: 160px; + display: flex; + flex-direction: column; + padding: 4px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: var(--panel, #1a1d23); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} +.bottom-terminal-actions .bottom-terminal-shell-item { + width: 100%; + height: auto; + justify-content: flex-start; + padding: 6px 10px; + border: none; + border-radius: 6px; + background: transparent; + box-shadow: none; + font-size: 12px; + color: var(--text); +} +.bottom-terminal-actions .bottom-terminal-shell-item:hover { + background: rgba(255, 255, 255, 0.08); +} + .kube-subtle { font-size: 11px; opacity: 0.6; diff --git a/crates/knock-core/src/kubeconfigs.rs b/crates/knock-core/src/kubeconfigs.rs index 57e1e80..3fbf7c3 100644 --- a/crates/knock-core/src/kubeconfigs.rs +++ b/crates/knock-core/src/kubeconfigs.rs @@ -56,12 +56,17 @@ pub struct KubeEntryMeta { pub struct KubeconfigSettings { #[serde(default = "default_preferred_terminal")] pub preferred_terminal: String, + /// Embedded-PTY shell id (cmd/powershell/pwsh/git-bash/wsl/zsh/bash/fish), + /// or "auto" for the platform default. + #[serde(default = "default_preferred_shell")] + pub preferred_shell: String, } impl Default for KubeconfigSettings { fn default() -> Self { Self { preferred_terminal: default_preferred_terminal(), + preferred_shell: default_preferred_shell(), } } } @@ -70,6 +75,10 @@ fn default_preferred_terminal() -> String { "auto".to_string() } +fn default_preferred_shell() -> String { + "auto".to_string() +} + const SETTINGS_FILE: &str = "settings.json"; pub fn settings_path(dir: &Path) -> PathBuf { @@ -612,6 +621,7 @@ mod tests { d.path(), &KubeconfigSettings { preferred_terminal: "iterm".to_string(), + ..Default::default() }, ) .unwrap(); @@ -626,6 +636,7 @@ mod tests { d.path(), &KubeconfigSettings { preferred_terminal: "warp".to_string(), + ..Default::default() }, ) .unwrap(); From 5ff34112a829cc1a555515c3d6485d072a228221 Mon Sep 17 00:00:00 2001 From: Jairo Fernandez Date: Sun, 19 Jul 2026 11:51:32 -0500 Subject: [PATCH 2/2] style: apply cargo fmt to terminal_cmd --- apps/knock-app/src-tauri/src/terminal_cmd.rs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/apps/knock-app/src-tauri/src/terminal_cmd.rs b/apps/knock-app/src-tauri/src/terminal_cmd.rs index a34028d..9357593 100644 --- a/apps/knock-app/src-tauri/src/terminal_cmd.rs +++ b/apps/knock-app/src-tauri/src/terminal_cmd.rs @@ -530,11 +530,7 @@ const WINDOWS_SHELLS: &[(&str, &str)] = &[ ]; #[cfg(unix)] -const UNIX_SHELLS: &[(&str, &str)] = &[ - ("zsh", "zsh"), - ("bash", "bash"), - ("fish", "fish"), -]; +const UNIX_SHELLS: &[(&str, &str)] = &[("zsh", "zsh"), ("bash", "bash"), ("fish", "fish")]; fn shell_available(id: &str) -> bool { #[cfg(windows)] @@ -598,10 +594,9 @@ fn shell_command(id: &str) -> (String, Vec) { #[cfg(windows)] { match id { - "powershell" if shell_available("powershell") => ( - "powershell.exe".to_string(), - vec!["-NoLogo".to_string()], - ), + "powershell" if shell_available("powershell") => { + ("powershell.exe".to_string(), vec!["-NoLogo".to_string()]) + } "pwsh" if shell_available("pwsh") => { ("pwsh.exe".to_string(), vec!["-NoLogo".to_string()]) }