app
Adom TTS — Text to Speech
Public Made by Adomby adom
Adom's shared voice service. Reads your AI's answers back to you while you drive, and renders perfectly-timed voiceover tracks for your demo videos — so you never record your own voice.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
//! Tiny HTTP server for the TTS playground — Hydrogen webview backend.
//!
//! Every UI action has a curl-equivalent on this server (app-creator §7).
//! Synthesis hits the shared service-tts container so pronunciation
//! overrides + source-hash cache apply uniformly.
use crate::history;
use anyhow::Result;
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use serde::Deserialize;
use std::collections::VecDeque;
use std::io::Cursor;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
const UI_HTML: &str = include_str!("ui.html");
pub(crate) const FAVICON_SVG: &str = include_str!("../docs/icon.svg");
const CONSOLE_CAP: usize = 500;
const TAB_NAME: &str = "TTS Playground";
pub(crate) const PANEL_TYPE: &str = "adom/a1b2c3d4-0031-4000-a000-000000000031";
pub struct ServeConfig {
pub port: u16,
pub service_url: String,
pub open_tab: bool,
/// Reserved for the /eval hot-patch channel (app-creator §7b).
#[allow(dead_code)]
pub dev: bool,
}
struct Shared {
console: Mutex<VecDeque<serde_json::Value>>,
last_synth_at: Mutex<Option<String>>,
voices_cache: Mutex<Option<String>>,
shutdown: AtomicBool,
}
pub fn run(cfg: ServeConfig) -> Result<()> {
let addr = format!("0.0.0.0:{}", cfg.port);
let server = Server::http(&addr)
.map_err(|e| anyhow::anyhow!("bind {addr}: {e}"))?;
let server = Arc::new(server);
let shared = Arc::new(Shared {
console: Mutex::new(VecDeque::with_capacity(CONSOLE_CAP)),
last_synth_at: Mutex::new(None),
voices_cache: Mutex::new(None),
shutdown: AtomicBool::new(false),
});
let url = webview_url(cfg.port);
println!("OK: adom-tts serve listening on {addr}");
println!("OK: webview url: {url}");
if cfg.open_tab {
match open_webview_tab(&url) {
Ok(()) => println!("OK: opened Hydrogen webview tab \"{TAB_NAME}\""),
Err(e) => eprintln!("Hint: could not open webview tab ({e}) — visit {url} manually"),
}
}
for request in server.incoming_requests() {
if shared.shutdown.load(Ordering::SeqCst) {
break;
}
let shared = shared.clone();
let server = server.clone();
let cfg_service_url = cfg.service_url.clone();
thread::spawn(move || {
handle(request, &shared, &cfg_service_url, &server);
});
}
if cfg.open_tab {
let _ = close_webview_tab();
}
println!("OK: shutdown complete");
Ok(())
}
fn handle(request: Request, shared: &Shared, service_url: &str, server: &Server) {
let method = request.method().clone();
let raw = request.url().to_string();
let url = raw.split('?').next().unwrap_or("").to_string();
let result: std::io::Result<()> = match (&method, url.as_str()) {
(Method::Get, "/" | "/index.html") => respond_html(request, UI_HTML),
(Method::Get, "/favicon.svg") => respond(request, 200, "image/svg+xml", FAVICON_SVG.as_bytes().to_vec()),
(Method::Get, "/favicon.ico") => respond(request, 200, "image/svg+xml", FAVICON_SVG.as_bytes().to_vec()),
(Method::Get, "/state") => handle_state(request, shared, service_url),
(Method::Get, "/voices") => handle_voices(request, shared, service_url),
(Method::Get, "/health") => handle_health(request, service_url),
(Method::Get, "/pronunciations") => handle_pronunciations(request, service_url),
(Method::Post, "/pronunciation/propose") => handle_pron_propose(request),
(Method::Get, "/pronunciation/proposals") => handle_pron_proposals(request),
(Method::Post, "/synth") => handle_synth(request, shared, service_url),
(Method::Get, "/history") => handle_history_list(request),
(Method::Post, "/console") => handle_console_append(request, shared),
(Method::Get, "/console") => handle_console_tail(request, shared),
(Method::Delete, "/console") => handle_console_clear(request, shared),
(Method::Post | Method::Get, "/shutdown") => {
let _ = respond_json(request, 200, &serde_json::json!({"ok": true}));
shared.shutdown.store(true, Ordering::SeqCst);
server.unblock();
return;
}
(Method::Get, path) if path.starts_with("/history/") => {
let id = path.trim_start_matches("/history/").trim_end_matches(".mp3");
handle_history_audio(request, id)
}
(Method::Delete, path) if path.starts_with("/history/") => {
let id = path.trim_start_matches("/history/");
handle_history_delete(request, id)
}
_ => respond(request, 404, "application/json",
b"{\"ok\":false,\"error\":\"not found\"}".to_vec()),
};
if let Err(e) = result {
eprintln!("adom-tts serve: request error: {e}");
}
}
fn handle_state(req: Request, shared: &Shared, service_url: &str) -> std::io::Result<()> {
let count = history::list().map(|v| v.len()).unwrap_or(0);
let last = shared.last_synth_at.lock().unwrap().clone();
respond_json(req, 200, &serde_json::json!({
"ok": true,
"version": env!("CARGO_PKG_VERSION"),
"service_url": service_url,
"history_count": count,
"last_synth_at": last,
}))
}
fn handle_health(req: Request, service_url: &str) -> std::io::Result<()> {
match reqwest::blocking::Client::new()
.get(format!("{service_url}/health"))
.timeout(std::time::Duration::from_secs(5))
.send()
{
Ok(r) => {
let status = r.status().as_u16();
let body = r.text().unwrap_or_default();
respond(req, status, "application/json", body.into_bytes())
}
Err(e) => respond_json(req, 502, &serde_json::json!({
"ok": false, "error": format!("service-tts unreachable: {e}")
})),
}
}
fn handle_voices(req: Request, shared: &Shared, service_url: &str) -> std::io::Result<()> {
{
let cache = shared.voices_cache.lock().unwrap();
if let Some(body) = cache.as_ref() {
return respond(req, 200, "application/json", body.clone().into_bytes());
}
}
match reqwest::blocking::Client::new()
.get(format!("{service_url}/tts/voices"))
.timeout(std::time::Duration::from_secs(15))
.send()
{
Ok(r) if r.status().is_success() => {
let body = r.text().unwrap_or_default();
*shared.voices_cache.lock().unwrap() = Some(body.clone());
respond(req, 200, "application/json", body.into_bytes())
}
Ok(r) => respond_json(req, r.status().as_u16(), &serde_json::json!({
"ok": false, "error": format!("service-tts HTTP {}", r.status())
})),
Err(e) => respond_json(req, 502, &serde_json::json!({
"ok": false, "error": format!("service-tts unreachable: {e}")
})),
}
}
#[derive(Deserialize)]
struct SynthReq {
text: String,
#[serde(default = "default_voice")]
voice: String,
#[serde(default = "default_rate")]
rate: String,
#[serde(default = "default_apply")]
apply_pronunciations: bool,
}
fn default_voice() -> String { "en-US-AndrewNeural".into() }
fn default_rate() -> String { "+0%".into() }
fn default_apply() -> bool { true }
fn handle_synth(mut req: Request, shared: &Shared, service_url: &str) -> std::io::Result<()> {
let mut body = String::new();
std::io::Read::read_to_string(req.as_reader(), &mut body).ok();
let parsed: SynthReq = match serde_json::from_str(&body) {
Ok(v) => v,
Err(e) => return respond_json(req, 400, &serde_json::json!({
"ok": false, "error": format!("bad json: {e}")
})),
};
if parsed.text.trim().is_empty() {
return respond_json(req, 400, &serde_json::json!({
"ok": false, "error": "text is empty"
}));
}
let resp = match reqwest::blocking::Client::new()
.post(format!("{service_url}/tts/say"))
.json(&serde_json::json!({
"text": parsed.text,
"voice": parsed.voice,
"rate": parsed.rate,
"apply_pronunciations": parsed.apply_pronunciations,
}))
.timeout(std::time::Duration::from_secs(60))
.send()
{
Ok(r) => r,
Err(e) => return respond_json(req, 502, &serde_json::json!({
"ok": false, "error": format!("service-tts unreachable: {e}")
})),
};
if !resp.status().is_success() {
let status = resp.status().as_u16();
return respond_json(req, status, &serde_json::json!({
"ok": false, "error": format!("service-tts HTTP {status}")
}));
}
let hash = resp.headers().get("x-tts-hash")
.and_then(|v| v.to_str().ok()).map(str::to_string);
let bytes = match resp.bytes() {
Ok(b) => b.to_vec(),
Err(e) => return respond_json(req, 502, &serde_json::json!({
"ok": false, "error": format!("read body: {e}")
})),
};
let clip = match history::save(&bytes, &parsed.text, &parsed.voice, &parsed.rate, "ui", hash) {
Ok(c) => c,
Err(e) => return respond_json(req, 500, &serde_json::json!({
"ok": false, "error": format!("save clip: {e}")
})),
};
*shared.last_synth_at.lock().unwrap() = Some(clip.created_at.clone());
respond_json(req, 200, &serde_json::json!({"ok": true, "clip": clip}))
}
fn handle_pronunciations(req: Request, service_url: &str) -> std::io::Result<()> {
match reqwest::blocking::Client::new()
.get(format!("{service_url}/tts/pronunciations"))
.timeout(std::time::Duration::from_secs(10))
.send()
{
Ok(r) => {
let status = r.status().as_u16();
let body = r.text().unwrap_or_default();
respond(req, status, "application/json", body.into_bytes())
}
Err(e) => respond_json(req, 502, &serde_json::json!({
"ok": false, "error": format!("service-tts unreachable: {e}")
})),
}
}
#[derive(Deserialize)]
struct PronProposal {
written: String,
narration: String,
#[serde(default)]
reason: String,
}
fn gallia_pronunciations_path() -> Option<std::path::PathBuf> {
use std::path::PathBuf;
let candidates: Vec<PathBuf> = [
std::env::var("ADOM_GALLIA").ok(),
Some(format!("{}/project/gallia", std::env::var("HOME").unwrap_or_default())),
Some("/home/adom/project/gallia".into()),
].into_iter().flatten()
.map(|s| PathBuf::from(s).join("skills/tts-pronunciation/pronunciations.json"))
.collect();
candidates.into_iter().find(|p| p.exists())
}
fn handle_pron_propose(mut req: Request) -> std::io::Result<()> {
let mut body = String::new();
std::io::Read::read_to_string(req.as_reader(), &mut body).ok();
let p: PronProposal = match serde_json::from_str(&body) {
Ok(v) => v,
Err(e) => return respond_json(req, 400, &serde_json::json!({
"ok": false, "error": format!("bad json: {e}")
})),
};
if p.written.trim().is_empty() || p.narration.trim().is_empty() {
return respond_json(req, 400, &serde_json::json!({
"ok": false, "error": "written and narration are required"
}));
}
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
let added_by = std::env::var("ADOM_USER")
.or_else(|_| std::env::var("USER"))
.unwrap_or_else(|_| "unknown".into());
let reason = if p.reason.trim().is_empty() {
"Added via TTS Playground.".to_string()
} else { p.reason.trim().to_string() };
let entry = serde_json::json!({
"written": p.written.trim(),
"narration": p.narration.trim(),
"engines_confirmed": ["edge-tts"],
"reason": reason,
"added_by": added_by,
"added_date": today,
});
// Try to write directly into the gallia skill file.
if let Some(path) = gallia_pronunciations_path() {
match append_to_gallia(&path, &entry) {
Ok(n) => {
return respond_json(req, 200, &serde_json::json!({
"ok": true,
"target": "gallia",
"path": path.to_string_lossy(),
"total_entries": n,
"entry": entry,
"next_step": format!(
"commit + push {} so the service-tts container picks it up on next redeploy",
path.display()
),
}));
}
Err(e) => {
eprintln!("adom-tts: gallia append failed ({e}) — falling back to proposal queue");
}
}
}
// Fallback: append to local proposals queue.
let queue = proposals_path();
if let Some(parent) = queue.parent() { let _ = std::fs::create_dir_all(parent); }
let mut list: Vec<serde_json::Value> = std::fs::read_to_string(&queue).ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
list.push(entry.clone());
if let Err(e) = std::fs::write(&queue, serde_json::to_vec_pretty(&list).unwrap_or_default()) {
return respond_json(req, 500, &serde_json::json!({
"ok": false, "error": format!("write queue: {e}")
}));
}
respond_json(req, 200, &serde_json::json!({
"ok": true,
"target": "queue",
"path": queue.to_string_lossy(),
"total_entries": list.len(),
"entry": entry,
"next_step": format!(
"gallia checkout not found — entry saved to {}. Paste this JSON into gallia/skills/tts-pronunciation/pronunciations.json > entries when you're ready.",
queue.display()
),
}))
}
fn proposals_path() -> std::path::PathBuf {
let home = std::env::var("HOME").unwrap_or_default();
std::path::PathBuf::from(home).join(".adom/tts/proposed-pronunciations.json")
}
fn handle_pron_proposals(req: Request) -> std::io::Result<()> {
let queue = proposals_path();
let list: Vec<serde_json::Value> = std::fs::read_to_string(&queue).ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
respond_json(req, 200, &serde_json::json!({
"ok": true,
"path": queue.to_string_lossy(),
"proposals": list,
}))
}
/// Append an entry to the gallia pronunciations.json `entries` array while
/// preserving the file's formatting. Returns the new entry count.
fn append_to_gallia(path: &std::path::Path, entry: &serde_json::Value) -> anyhow::Result<usize> {
let src = std::fs::read_to_string(path)?;
let mut doc: serde_json::Value = serde_json::from_str(&src)?;
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
if let Some(obj) = doc.as_object_mut() {
obj.insert("updated".to_string(), serde_json::Value::String(today));
let entries = obj.get_mut("entries")
.and_then(|v| v.as_array_mut())
.ok_or_else(|| anyhow::anyhow!("no entries array"))?;
// Dedupe by `written` (case-insensitive).
let written_lc = entry.get("written").and_then(|s| s.as_str())
.map(|s| s.to_lowercase()).unwrap_or_default();
entries.retain(|e| {
e.get("written").and_then(|s| s.as_str())
.map(|s| s.to_lowercase()) != Some(written_lc.clone())
});
entries.push(entry.clone());
let n = entries.len();
std::fs::write(path, serde_json::to_string_pretty(&doc)? + "\n")?;
Ok(n)
} else {
anyhow::bail!("pronunciations.json is not a JSON object");
}
}
fn handle_history_list(req: Request) -> std::io::Result<()> {
match history::list() {
Ok(clips) => respond_json(req, 200, &serde_json::json!({"ok": true, "clips": clips})),
Err(e) => respond_json(req, 500, &serde_json::json!({
"ok": false, "error": format!("{e}")
})),
}
}
fn handle_history_audio(req: Request, id: &str) -> std::io::Result<()> {
let path = match history::audio_path(id) {
Ok(p) => p,
Err(_) => return respond(req, 404, "application/json",
b"{\"ok\":false,\"error\":\"not found\"}".to_vec()),
};
let bytes = std::fs::read(&path)?;
respond(req, 200, "audio/mpeg", bytes)
}
fn handle_history_delete(req: Request, id: &str) -> std::io::Result<()> {
match history::delete(id) {
Ok(()) => respond_json(req, 200, &serde_json::json!({"ok": true, "id": id})),
Err(e) => respond_json(req, 500, &serde_json::json!({
"ok": false, "error": format!("{e}")
})),
}
}
fn handle_console_append(mut req: Request, shared: &Shared) -> std::io::Result<()> {
let mut body = String::new();
std::io::Read::read_to_string(req.as_reader(), &mut body).ok();
let entry: serde_json::Value = serde_json::from_str(&body)
.unwrap_or_else(|_| serde_json::json!({"level":"log","msg":body}));
let mut buf = shared.console.lock().unwrap();
if buf.len() >= CONSOLE_CAP { buf.pop_front(); }
buf.push_back(entry);
respond_json(req, 200, &serde_json::json!({"ok": true}))
}
fn handle_console_tail(req: Request, shared: &Shared) -> std::io::Result<()> {
let buf = shared.console.lock().unwrap();
let all: Vec<_> = buf.iter().cloned().collect();
respond_json(req, 200, &serde_json::json!({"ok": true, "entries": all}))
}
fn handle_console_clear(req: Request, shared: &Shared) -> std::io::Result<()> {
shared.console.lock().unwrap().clear();
respond_json(req, 200, &serde_json::json!({"ok": true}))
}
fn respond(req: Request, status: u16, content_type: &str, body: Vec<u8>) -> std::io::Result<()> {
let ct = Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes())
.expect("valid header");
let len = body.len();
let cursor = Cursor::new(body);
let resp = Response::new(StatusCode(status), vec![ct], cursor, Some(len), None);
req.respond(resp)
}
fn respond_html(req: Request, body: &str) -> std::io::Result<()> {
respond(req, 200, "text/html; charset=utf-8", body.as_bytes().to_vec())
}
fn respond_json(req: Request, status: u16, value: &serde_json::Value) -> std::io::Result<()> {
let bytes = serde_json::to_vec(value).unwrap_or_else(|_| b"{}".to_vec());
respond(req, status, "application/json", bytes)
}
pub(crate) fn webview_url(port: u16) -> String {
match std::env::var("VSCODE_PROXY_URI") {
Ok(template) => template.replace("{{port}}", &port.to_string()),
Err(_) => format!("http://127.0.0.1:{port}/"),
}
}
fn open_webview_tab(url: &str) -> Result<()> {
open_webview_tab_named(url, TAB_NAME)
}
pub(crate) fn open_webview_tab_named(url: &str, display_name: &str) -> Result<()> {
let workspace = Command::new("adom-cli")
.args(["hydrogen", "workspace", "get"])
.output()?;
if !workspace.status.success() {
anyhow::bail!("adom-cli hydrogen workspace get failed");
}
let stdout = String::from_utf8_lossy(&workspace.stdout);
let icon_data = format!("data:image/svg+xml;base64,{}", B64.encode(FAVICON_SVG.as_bytes()));
let initial = serde_json::json!({"url": url}).to_string();
if let Some(leaf_id) = pick_non_vscode_leaf(&stdout) {
// open-or-refresh REUSES the existing "TTS Player" tab (navigates +
// reloads it) if one exists, else creates it in `leaf_id`. The stable
// name means every clip from every thread lands in ONE tab — no pile.
let out = Command::new("adom-cli")
.args([
"hydrogen", "webview", "open-or-refresh",
"--name", display_name,
"--url", url,
"--panel-id", &leaf_id,
"--panel-type", PANEL_TYPE,
"--display-icon", &icon_data,
])
.output()?;
if !out.status.success() {
anyhow::bail!("open-or-refresh failed: {}", String::from_utf8_lossy(&out.stderr));
}
// Blink the tab so a foregrounded webview renders + autoplays (a
// backgrounded tab is deferred and never plays) and the user notices.
let _ = Command::new("adom-cli")
.args(["hydrogen", "alert", "set", "--name", display_name])
.output();
return Ok(());
}
// No non-VS-Code panel exists — split the VS Code pane to make one.
// Per `feedback_never_cover_vscode.md` we never put a tab on top of
// VS Code; splitting creates a sibling pane instead.
let vscode_id = pick_vscode_leaf(&stdout)
.ok_or_else(|| anyhow::anyhow!("no panel found in workspace"))?;
let split = Command::new("adom-cli")
.args([
"hydrogen", "workspace", "split",
"--panel-id", &vscode_id,
"--direction", "horizontal",
"--position", "after",
// ratio = fraction for the FIRST child (the existing VS Code pane,
// since the player is added "after"/right). 0.80 → VS Code keeps
// four-fifths, the player takes the right 20% (hardcoded here in the
// CODE — never rely on a skill; the AI won't follow it perfectly).
"--ratio", "0.80",
"--panel-type", PANEL_TYPE,
"--display-name", display_name,
"--display-icon", &icon_data,
"--initial-state", &initial,
])
.output()?;
if !split.status.success() {
anyhow::bail!("split failed: {}", String::from_utf8_lossy(&split.stderr));
}
Ok(())
}
/// Inverse of `pick_non_vscode_leaf` — returns the first panel id whose
/// tabs include the VS Code panelType. Used as a split anchor when no
/// other host panel exists yet.
fn pick_vscode_leaf(workspace_json: &str) -> Option<String> {
const VSCODE_SUFFIX: &str = "eeee-4000-a000-00000000000e";
let v: serde_json::Value = serde_json::from_str(workspace_json).ok()?;
fn collect(node: &serde_json::Value, out: &mut Vec<serde_json::Value>) {
if let Some(obj) = node.as_object() {
if obj.get("type").and_then(|s| s.as_str()) == Some("leaf")
&& obj.contains_key("id")
&& obj.get("tabs").map(|t| t.is_array()).unwrap_or(false)
{
out.push(node.clone());
}
for (_, v) in obj { collect(v, out); }
} else if let Some(arr) = node.as_array() {
for v in arr { collect(v, out); }
}
}
let mut panels = Vec::new();
collect(&v, &mut panels);
panels.iter()
.find(|p| p.get("tabs").and_then(|t| t.as_array()).map(|tabs| {
tabs.iter().any(|t| t.get("panelType")
.and_then(|s| s.as_str())
.map(|s| s.ends_with(VSCODE_SUFFIX))
.unwrap_or(false))
}).unwrap_or(false))
.and_then(|p| p.get("id").and_then(|s| s.as_str()).map(str::to_string))
}
fn pick_non_vscode_leaf(workspace_json: &str) -> Option<String> {
// Workspace shape: tree of split containers (with `first`/`second`) and
// panels (with `tabs`). Panels are the leaf nodes that hold tabs — a tab
// itself carries the `panelType`, not the panel. We want a panel id
// whose tabs do NOT include the VS Code panelType (never cover VS Code).
let v: serde_json::Value = serde_json::from_str(workspace_json).ok()?;
let focused_panel = v.get("focusedPanelId").and_then(|s| s.as_str());
// VS Code tab panelType ends in "...eeee-4000-a000-00000000000e".
const VSCODE_SUFFIX: &str = "eeee-4000-a000-00000000000e";
fn collect_panels(node: &serde_json::Value, out: &mut Vec<serde_json::Value>) {
if let Some(obj) = node.as_object() {
if obj.get("type").and_then(|s| s.as_str()) == Some("leaf")
&& obj.contains_key("id")
&& obj.get("tabs").map(|t| t.is_array()).unwrap_or(false)
{
out.push(node.clone());
}
for (_, v) in obj { collect_panels(v, out); }
} else if let Some(arr) = node.as_array() {
for v in arr { collect_panels(v, out); }
}
}
let mut panels = Vec::new();
collect_panels(&v, &mut panels);
let covers_vscode = |p: &serde_json::Value| -> bool {
p.get("tabs").and_then(|t| t.as_array()).map(|tabs| {
tabs.iter().any(|t| t.get("panelType")
.and_then(|s| s.as_str())
.map(|s| s.ends_with(VSCODE_SUFFIX))
.unwrap_or(false))
}).unwrap_or(false)
};
let ok_panels: Vec<_> = panels.into_iter().filter(|p| !covers_vscode(p)).collect();
if ok_panels.is_empty() { return None; }
// Prefer the focused panel if it's not a VS Code host.
if let Some(fid) = focused_panel {
if let Some(m) = ok_panels.iter()
.find(|p| p.get("id").and_then(|s| s.as_str()) == Some(fid))
{
return m.get("id").and_then(|s| s.as_str()).map(str::to_string);
}
}
ok_panels.first()
.and_then(|p| p.get("id").and_then(|s| s.as_str()).map(str::to_string))
}
fn close_webview_tab() -> Result<()> {
let _ = Command::new("adom-cli")
.args(["hydrogen", "workspace", "remove-tab", "--name", TAB_NAME])
.output();
Ok(())
}