1use crate::runtime::{runtime_bridge_path, runtime_resources_dir};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4use taskers_domain::{BrowserProfileMode, PaneKind};
5use taskers_runtime::ShellLaunchSpec;
6use thiserror::Error;
7
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum EmbeddedTerminalAppearance {
11 #[default]
12 Taskers,
13 Ghostty,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum BackendChoice {
19 Auto,
20 Ghostty,
21 GhosttyEmbedded,
22 Mock,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum BackendAvailability {
28 Ready,
29 Fallback,
30 Unavailable,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct BackendProbe {
35 pub requested: BackendChoice,
36 pub selected: BackendChoice,
37 pub availability: BackendAvailability,
38 pub notes: String,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct SurfaceDescriptor {
43 pub cols: u16,
44 pub rows: u16,
45 pub kind: PaneKind,
46 pub cwd: Option<String>,
47 pub title: Option<String>,
48 pub url: Option<String>,
49 #[serde(default)]
50 pub browser_profile_mode: BrowserProfileMode,
51 #[serde(default)]
52 pub command_argv: Vec<String>,
53 #[serde(default)]
54 pub env: BTreeMap<String, String>,
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
58pub struct GhosttyHostOptions {
59 #[serde(default)]
60 pub command_argv: Vec<String>,
61 #[serde(default)]
62 pub env: BTreeMap<String, String>,
63 #[serde(default)]
64 pub embedded_terminal_appearance: EmbeddedTerminalAppearance,
65}
66
67impl GhosttyHostOptions {
68 pub fn from_shell_launch(shell_launch: &ShellLaunchSpec) -> Self {
69 let mut env = BTreeMap::new();
70 env.extend(shell_launch.env.clone());
71 Self {
72 command_argv: shell_launch.program_and_args(),
73 env,
74 embedded_terminal_appearance: EmbeddedTerminalAppearance::Taskers,
75 }
76 }
77
78 pub fn with_embedded_terminal_appearance(
79 mut self,
80 embedded_terminal_appearance: EmbeddedTerminalAppearance,
81 ) -> Self {
82 self.embedded_terminal_appearance = embedded_terminal_appearance;
83 self
84 }
85}
86
87#[derive(Debug, Error)]
88pub enum AdapterError {
89 #[error("terminal backend is unavailable: {0}")]
90 Unavailable(String),
91 #[error("terminal backend initialization failed: {0}")]
92 Initialization(String),
93}
94
95pub trait TerminalBackend {
96 fn probe(requested: BackendChoice) -> BackendProbe;
97}
98
99pub struct DefaultBackend;
100
101impl TerminalBackend for DefaultBackend {
102 fn probe(requested: BackendChoice) -> BackendProbe {
103 let env_override = std::env::var("TASKERS_TERMINAL_BACKEND").ok();
104 let requested = match env_override.as_deref() {
105 Some("ghostty") => BackendChoice::Ghostty,
106 Some("ghostty_embedded") | Some("ghostty-embedded") => BackendChoice::GhosttyEmbedded,
107 Some("mock") => BackendChoice::Mock,
108 _ => requested,
109 };
110
111 match requested {
112 BackendChoice::Auto => auto_probe(requested),
113 BackendChoice::Mock => BackendProbe {
114 requested,
115 selected: BackendChoice::Mock,
116 availability: BackendAvailability::Fallback,
117 notes: "Using placeholder terminal surfaces.".into(),
118 },
119 BackendChoice::GhosttyEmbedded => BackendProbe {
120 requested,
121 selected: BackendChoice::GhosttyEmbedded,
122 availability: embedded_ghostty_availability(),
123 notes: embedded_ghostty_notes(),
124 },
125 BackendChoice::Ghostty => BackendProbe {
126 requested,
127 selected: BackendChoice::Ghostty,
128 availability: ghostty_availability(),
129 notes: ghostty_notes(),
130 },
131 }
132 }
133}
134
135fn auto_probe(requested: BackendChoice) -> BackendProbe {
136 let availability = ghostty_availability();
137 if matches!(availability, BackendAvailability::Ready) {
138 BackendProbe {
139 requested,
140 selected: BackendChoice::Ghostty,
141 availability,
142 notes: ghostty_notes(),
143 }
144 } else {
145 BackendProbe {
146 requested,
147 selected: BackendChoice::Mock,
148 availability: BackendAvailability::Fallback,
149 notes: "Ghostty bridge unavailable, using placeholder terminal surfaces.".into(),
150 }
151 }
152}
153
154fn ghostty_availability() -> BackendAvailability {
155 #[cfg(all(target_os = "linux", taskers_ghostty_bridge))]
156 {
157 if runtime_bridge_path().is_some() {
158 BackendAvailability::Ready
159 } else {
160 BackendAvailability::Unavailable
161 }
162 }
163
164 #[cfg(not(all(target_os = "linux", taskers_ghostty_bridge)))]
165 {
166 BackendAvailability::Unavailable
167 }
168}
169
170fn embedded_ghostty_availability() -> BackendAvailability {
171 #[cfg(target_os = "macos")]
172 {
173 BackendAvailability::Ready
174 }
175
176 #[cfg(not(target_os = "macos"))]
177 {
178 BackendAvailability::Unavailable
179 }
180}
181
182fn ghostty_notes() -> String {
183 let mut notes = String::from("Ghostty GTK bridge compiled in.");
184 if let Some(path) = runtime_bridge_path() {
185 notes.push_str(" Bridge: ");
186 notes.push_str(&path.display().to_string());
187 } else {
188 notes.push_str(" Bridge library not found.");
189 }
190 if let Some(path) = runtime_resources_dir() {
191 notes.push_str(" Resources: ");
192 notes.push_str(&path.display().to_string());
193 }
194 notes
195}
196
197fn embedded_ghostty_notes() -> String {
198 String::from("Embedded Ghostty surfaces require the native macOS host.")
199}
200
201#[cfg(test)]
202mod tests {
203 use super::{
204 BackendAvailability, BackendChoice, DefaultBackend, EmbeddedTerminalAppearance,
205 GhosttyHostOptions, TerminalBackend,
206 };
207 use std::{collections::BTreeMap, path::PathBuf, sync::Mutex};
208 use taskers_runtime::ShellLaunchSpec;
209
210 static BACKEND_ENV_LOCK: Mutex<()> = Mutex::new(());
211
212 #[test]
213 fn auto_probe_matches_runtime_availability() {
214 let _guard = BACKEND_ENV_LOCK.lock().expect("env lock");
215 unsafe { std::env::remove_var("TASKERS_TERMINAL_BACKEND") };
216 let probe = DefaultBackend::probe(BackendChoice::Auto);
217 match probe.availability {
218 BackendAvailability::Ready => assert_eq!(probe.selected, BackendChoice::Ghostty),
219 BackendAvailability::Fallback | BackendAvailability::Unavailable => {
220 assert_eq!(probe.selected, BackendChoice::Mock);
221 }
222 }
223 }
224
225 #[test]
226 fn embedded_probe_stays_explicit() {
227 let probe = DefaultBackend::probe(BackendChoice::GhosttyEmbedded);
228 assert_eq!(probe.selected, BackendChoice::GhosttyEmbedded);
229
230 #[cfg(target_os = "macos")]
231 assert_eq!(probe.availability, BackendAvailability::Ready);
232
233 #[cfg(not(target_os = "macos"))]
234 assert_eq!(probe.availability, BackendAvailability::Unavailable);
235 }
236
237 #[test]
238 fn env_override_accepts_hyphenated_embedded_backend() {
239 let _guard = BACKEND_ENV_LOCK.lock().expect("env lock");
240 unsafe { std::env::set_var("TASKERS_TERMINAL_BACKEND", "ghostty-embedded") };
241 let probe = DefaultBackend::probe(BackendChoice::Mock);
242 unsafe { std::env::remove_var("TASKERS_TERMINAL_BACKEND") };
243 assert_eq!(probe.selected, BackendChoice::GhosttyEmbedded);
244 }
245
246 #[test]
247 fn host_options_follow_shell_launch_contract() {
248 let mut env = BTreeMap::new();
249 env.insert("TASKERS_SOCKET".into(), "/tmp/taskers.sock".into());
250 let shell_launch = ShellLaunchSpec {
251 program: PathBuf::from("/bin/zsh"),
252 args: vec!["-i".into()],
253 env,
254 };
255
256 let options = GhosttyHostOptions::from_shell_launch(&shell_launch);
257
258 assert_eq!(options.command_argv, vec!["/bin/zsh", "-i"]);
259 assert_eq!(
260 options.env.get("TASKERS_SOCKET").map(String::as_str),
261 Some("/tmp/taskers.sock")
262 );
263 assert_eq!(
264 options.embedded_terminal_appearance,
265 EmbeddedTerminalAppearance::Taskers
266 );
267 }
268
269 #[test]
270 fn host_options_allow_overriding_embedded_terminal_appearance() {
271 let options = GhosttyHostOptions::default()
272 .with_embedded_terminal_appearance(EmbeddedTerminalAppearance::Ghostty);
273 assert_eq!(
274 options.embedded_terminal_appearance,
275 EmbeddedTerminalAppearance::Ghostty
276 );
277 }
278}