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