Skip to main content

beam_worker/
lib.rs

1mod adapter;
2mod adapters;
3mod backend;
4
5use std::path::Path;
6use std::sync::Arc;
7use std::sync::LazyLock;
8use std::sync::Mutex as StdMutex;
9use std::time::Duration;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use ab_glyph::{Font, FontVec, PxScale, ScaleFont, point};
13use anyhow::{Context, Result};
14use beam_core::{
15    BeamPaths, CliUsageLimitKind, CliUsageLimitState, DaemonToWorker, DisplayMode, InitConfig,
16    ScreenAnalyzerConfig, ScreenStatus, TermActionKey, TuiPromptOption, WorkerToDaemon,
17};
18use image::{ColorType, ImageBuffer, ImageEncoder, Rgba, codecs::png::PngEncoder};
19use reqwest::multipart::{Form, Part};
20use reqwest::{Client, header::HeaderMap};
21use serde::Deserialize;
22use sha2::{Digest, Sha256};
23use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
24use tokio::sync::{Mutex, RwLock, broadcast};
25use tracing::{info, warn};
26use unicode_width::UnicodeWidthChar;
27use uuid::Uuid;
28
29use crate::adapter::CliAdapter;
30use crate::backend::{SessionBackend, SpawnOpts, ZellijBackend, ZellijObserveBackend};
31
32const FALLBACK_TERMINAL_COLS: u16 = 120;
33const FALLBACK_TERMINAL_ROWS: u16 = 36;
34const CARD_VIEWPORT_COLS: usize = 120;
35const CARD_VIEWPORT_ROWS: usize = 36;
36
37fn render_screen_for_display_mode(screen: &str, mode: DisplayMode) -> String {
38    match mode {
39        DisplayMode::Hidden => "[screen hidden]".to_string(),
40        DisplayMode::Screenshot => card_viewport_text(screen),
41    }
42}
43
44fn crop_line_to_cells(line: &str, max_cols: usize) -> String {
45    let mut out = String::new();
46    let mut cols = 0usize;
47    for ch in line.chars() {
48        let width = if is_fullwidth(ch) { 2 } else { 1 };
49        if cols + width > max_cols {
50            break;
51        }
52        out.push(ch);
53        cols += width;
54    }
55    out
56}
57
58fn card_viewport_text(screen_raw: &str) -> String {
59    let clean = strip_ansi(screen_raw).replace('\r', "");
60    let mut out = String::new();
61    for (idx, line) in clean.lines().take(CARD_VIEWPORT_ROWS).enumerate() {
62        if idx > 0 {
63            out.push('\n');
64        }
65        out.push_str(&crop_line_to_cells(line, CARD_VIEWPORT_COLS));
66    }
67    out
68}
69
70const SCREEN_ANALYZER_SYSTEM_PROMPT: &str = "You are a terminal screen analyzer. Determine whether the CLI is showing a blocking interactive prompt. Return only JSON with fields needsInteraction, description, options, multiSelect, toggleKey, confirmKey, checkAgainWhen. checkAgainWhen must be one of content_changed, after_5s, after_10s, not_needed.";
71
72#[derive(Debug, Clone, Default)]
73struct AnalyzerRuntime {
74    last_snapshot: String,
75    stable_count: u32,
76    last_analyzed_snapshot: String,
77    waiting_for_content_change: bool,
78    cooldown_until_ms: u64,
79    is_analyzing: bool,
80    prompt_active: bool,
81}
82
83#[derive(Debug, Deserialize)]
84struct AnalyzerChatResponse {
85    choices: Vec<AnalyzerChoice>,
86}
87
88#[derive(Debug, Deserialize)]
89struct AnalyzerChoice {
90    message: AnalyzerMessage,
91}
92
93#[derive(Debug, Deserialize)]
94struct AnalyzerMessage {
95    content: String,
96}
97
98#[derive(Debug, Deserialize)]
99struct AnalyzerParsedResponse {
100    #[serde(rename = "needsInteraction")]
101    needs_interaction: Option<bool>,
102    description: Option<String>,
103    options: Option<Vec<AnalyzerParsedOption>>,
104    #[serde(rename = "multiSelect")]
105    multi_select: Option<bool>,
106    #[serde(rename = "toggleKey")]
107    toggle_key: Option<String>,
108    #[serde(rename = "confirmKey")]
109    confirm_key: Option<String>,
110    #[serde(rename = "checkAgainWhen")]
111    check_again_when: Option<String>,
112}
113
114#[derive(Debug, Deserialize)]
115struct AnalyzerParsedOption {
116    label: Option<String>,
117    text: Option<String>,
118    selected: Option<bool>,
119    #[serde(rename = "type")]
120    option_type: Option<String>,
121    index: Option<usize>,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
125struct AnalyzerResult {
126    needs_interaction: bool,
127    description: Option<String>,
128    options: Vec<TuiPromptOption>,
129    multi_select: bool,
130    check_again_when: String,
131}
132
133fn screen_analyzer_enabled(cfg: &ScreenAnalyzerConfig) -> bool {
134    cfg.enabled
135        && !cfg.base_url.trim().is_empty()
136        && !cfg.api_key.trim().is_empty()
137        && !cfg.model.trim().is_empty()
138}
139
140fn now_ms() -> u64 {
141    SystemTime::now()
142        .duration_since(UNIX_EPOCH)
143        .unwrap_or_default()
144        .as_millis() as u64
145}
146
147fn parse_screen_analyzer_response(content: &str) -> AnalyzerResult {
148    let json_str = content
149        .trim()
150        .trim_start_matches("```json")
151        .trim_start_matches("```")
152        .trim_end_matches("```")
153        .trim();
154    let parsed = serde_json::from_str::<AnalyzerParsedResponse>(json_str).ok();
155    let check_again_when = parsed
156        .as_ref()
157        .and_then(|parsed| parsed.check_again_when.clone())
158        .filter(|value| {
159            matches!(
160                value.as_str(),
161                "content_changed" | "after_5s" | "after_10s" | "not_needed"
162            )
163        })
164        .unwrap_or_else(|| "content_changed".to_string());
165    let toggle_key = parsed
166        .as_ref()
167        .and_then(|parsed| parsed.toggle_key.clone())
168        .unwrap_or_else(|| "Space".to_string());
169    let confirm_key = parsed
170        .as_ref()
171        .and_then(|parsed| parsed.confirm_key.clone())
172        .unwrap_or_else(|| "Enter".to_string());
173    let options = parsed
174        .as_ref()
175        .and_then(|parsed| parsed.options.as_ref())
176        .map(|options| build_tui_prompt_options(options, &toggle_key, &confirm_key))
177        .unwrap_or_default();
178    AnalyzerResult {
179        needs_interaction: parsed
180            .as_ref()
181            .and_then(|parsed| parsed.needs_interaction)
182            .unwrap_or(false),
183        description: parsed
184            .as_ref()
185            .and_then(|parsed| parsed.description.clone()),
186        options,
187        multi_select: parsed
188            .as_ref()
189            .and_then(|parsed| parsed.multi_select)
190            .unwrap_or(false),
191        check_again_when,
192    }
193}
194
195fn apply_screen_analyzer_result(
196    runtime: &mut AnalyzerRuntime,
197    check_again_when: &str,
198    now_ms: u64,
199) {
200    match check_again_when {
201        "after_5s" => {
202            runtime.cooldown_until_ms = now_ms + 5_000;
203            runtime.waiting_for_content_change = false;
204        }
205        "after_10s" => {
206            runtime.cooldown_until_ms = now_ms + 10_000;
207            runtime.waiting_for_content_change = false;
208        }
209        "not_needed" | "content_changed" => {
210            runtime.waiting_for_content_change = true;
211            runtime.cooldown_until_ms = 0;
212        }
213        _ => {
214            runtime.waiting_for_content_change = true;
215            runtime.cooldown_until_ms = 0;
216        }
217    }
218}
219
220fn build_tui_prompt_options(
221    options: &[AnalyzerParsedOption],
222    toggle_key: &str,
223    confirm_key: &str,
224) -> Vec<TuiPromptOption> {
225    options
226        .iter()
227        .enumerate()
228        .map(|(i, option)| {
229            let index = option.index.unwrap_or(i);
230            let option_type = option
231                .option_type
232                .clone()
233                .filter(|kind| matches!(kind.as_str(), "select" | "toggle" | "confirm" | "input"))
234                .unwrap_or_else(|| "select".to_string());
235            let mut keys = Vec::new();
236            for _ in 0..index {
237                keys.push("Down".to_string());
238            }
239            match option_type.as_str() {
240                "toggle" => {
241                    keys.push(toggle_key.to_string());
242                    for _ in 0..index {
243                        keys.push("Up".to_string());
244                    }
245                }
246                "select" | "confirm" | "input" => {
247                    keys.push(confirm_key.to_string());
248                }
249                _ => {}
250            }
251            TuiPromptOption {
252                label: option.label.clone().or_else(|| Some((i + 1).to_string())),
253                text: option
254                    .text
255                    .clone()
256                    .unwrap_or_default()
257                    .replace('\n', " ")
258                    .trim()
259                    .to_string(),
260                selected: option.selected.unwrap_or(false),
261                option_type: Some(option_type),
262                keys,
263            }
264        })
265        .collect()
266}
267
268async fn call_screen_analyzer(
269    client: &Client,
270    cfg: &ScreenAnalyzerConfig,
271    snapshot: &str,
272) -> Result<AnalyzerResult> {
273    let url = format!("{}/chat/completions", cfg.base_url.trim_end_matches('/'));
274    let mut body = serde_json::json!({
275        "model": cfg.model,
276        "messages": [
277            { "role": "system", "content": SCREEN_ANALYZER_SYSTEM_PROMPT },
278            { "role": "user", "content": snapshot },
279        ],
280        "temperature": 0,
281        "max_tokens": 2048,
282    });
283    if let Some(map) = body.as_object_mut() {
284        for (key, value) in &cfg.extra_body {
285            map.insert(key.clone(), value.clone());
286        }
287    }
288
289    let mut headers = HeaderMap::new();
290    headers.insert("content-type", "application/json".parse()?);
291    headers.insert("authorization", format!("Bearer {}", cfg.api_key).parse()?);
292    for (key, value) in &cfg.extra_headers {
293        headers.insert(key.parse::<reqwest::header::HeaderName>()?, value.parse()?);
294    }
295
296    let response = client
297        .post(url)
298        .headers(headers)
299        .json(&body)
300        .timeout(Duration::from_secs(15))
301        .send()
302        .await?;
303    let status = response.status();
304    if !status.is_success() {
305        let text = response.text().await.unwrap_or_default();
306        anyhow::bail!(
307            "screen analyzer API {}: {}",
308            status,
309            text.chars().take(200).collect::<String>()
310        );
311    }
312    let payload = response.json::<AnalyzerChatResponse>().await?;
313    let content = payload
314        .choices
315        .first()
316        .map(|choice| choice.message.content.clone())
317        .unwrap_or_default();
318    Ok(parse_screen_analyzer_response(&content))
319}
320
321fn term_action_keys(key: TermActionKey) -> Vec<String> {
322    match key {
323        TermActionKey::Esc => vec!["Escape".to_string()],
324        TermActionKey::CtrlC => vec!["C-c".to_string()],
325        TermActionKey::Tab => vec!["Tab".to_string()],
326        TermActionKey::Enter => vec!["Enter".to_string()],
327        TermActionKey::Space => vec!["Space".to_string()],
328        TermActionKey::Up => vec!["Up".to_string()],
329        TermActionKey::Down => vec!["Down".to_string()],
330        TermActionKey::Left => vec!["Left".to_string()],
331        TermActionKey::Right => vec!["Right".to_string()],
332        TermActionKey::HalfPageUp => vec!["PageUp".to_string()],
333        TermActionKey::HalfPageDown => vec!["PageDown".to_string()],
334    }
335}
336
337fn shell_quote(input: &str) -> String {
338    if input.is_empty() {
339        return "''".to_string();
340    }
341    if !input.bytes().any(|b| {
342        matches!(
343            b,
344            b' ' | b'\t'
345                | b'\n'
346                | b'\''
347                | b'"'
348                | b'\\'
349                | b'$'
350                | b'`'
351                | b'!'
352                | b'&'
353                | b'|'
354                | b';'
355                | b'<'
356                | b'>'
357                | b'('
358                | b')'
359                | b'['
360                | b']'
361                | b'{'
362                | b'}'
363                | b'*'
364                | b'?'
365                | b'#'
366        )
367    }) {
368        return input.to_string();
369    }
370    format!("'{}'", input.replace('\'', "'\"'\"'"))
371}
372
373fn has_pattern(text: &str, patterns: &[&str]) -> bool {
374    let lower = text.to_ascii_lowercase();
375    patterns.iter().any(|pattern| lower.contains(pattern))
376}
377
378#[derive(Debug, serde::Deserialize)]
379struct LarkTokenResponse {
380    code: i32,
381    msg: Option<String>,
382    tenant_access_token: Option<String>,
383}
384
385#[derive(Debug, serde::Deserialize)]
386struct LarkImageUploadResponse {
387    code: i32,
388    msg: Option<String>,
389    image_key: Option<String>,
390    data: Option<LarkImageUploadData>,
391}
392
393#[derive(Debug, serde::Deserialize)]
394struct LarkImageUploadData {
395    image_key: Option<String>,
396}
397
398fn parse_retry_time(text: &str, now_ms: u64) -> Option<(u64, String)> {
399    let lower = text.to_ascii_lowercase();
400    let marker = ["try again at ", "resets at ", "reset at ", "resets "]
401        .into_iter()
402        .find_map(|needle| lower.find(needle).map(|idx| (idx, needle)))?;
403    let start = marker.0 + marker.1.len();
404    let tail = text.get(start..)?.trim_start();
405    let mut chars = tail.chars().peekable();
406    let mut hour = String::new();
407    while let Some(ch) = chars.peek().copied() {
408        if ch.is_ascii_digit() {
409            hour.push(ch);
410            chars.next();
411        } else {
412            break;
413        }
414    }
415    if hour.is_empty() {
416        return None;
417    }
418    let mut minute = String::new();
419    if chars.peek() == Some(&':') {
420        chars.next();
421        while let Some(ch) = chars.peek().copied() {
422            if ch.is_ascii_digit() {
423                minute.push(ch);
424                chars.next();
425            } else {
426                break;
427            }
428        }
429    }
430    while let Some(ch) = chars.peek().copied() {
431        if ch.is_ascii_whitespace() {
432            chars.next();
433        } else {
434            break;
435        }
436    }
437    let mut meridiem = String::new();
438    while let Some(ch) = chars.peek().copied() {
439        if matches!(ch.to_ascii_lowercase(), 'a' | 'p' | 'm' | '.') {
440            meridiem.push(ch);
441            chars.next();
442        } else {
443            break;
444        }
445    }
446    let meridiem = meridiem.to_ascii_lowercase().replace('.', "");
447    if meridiem != "am" && meridiem != "pm" {
448        return None;
449    }
450    let raw_hour = hour.parse::<u32>().ok()?;
451    let minute = if minute.is_empty() {
452        0
453    } else {
454        minute.parse::<u32>().ok()?
455    };
456    if !(1..=12).contains(&raw_hour) || minute > 59 {
457        return None;
458    }
459    let now = chrono::DateTime::<chrono::Utc>::from(
460        SystemTime::UNIX_EPOCH + Duration::from_millis(now_ms),
461    );
462    let mut hour24 = raw_hour % 12;
463    if meridiem == "pm" {
464        hour24 += 12;
465    }
466    let mut retry_at = now
467        .date_naive()
468        .and_hms_opt(hour24, minute, 0)?
469        .and_utc()
470        .timestamp_millis() as u64;
471    if retry_at < now_ms && hour24 < 12 {
472        retry_at += 24 * 60 * 60 * 1000;
473    }
474    let label = tail
475        .split_whitespace()
476        .take(2)
477        .collect::<Vec<_>>()
478        .join(" ")
479        .trim_end_matches(|ch: char| ch == '.' || ch == ',' || ch == ';')
480        .to_string();
481    Some((retry_at, label))
482}
483
484fn detect_cli_usage_limit(text: &str, now_ms: u64) -> Option<CliUsageLimitState> {
485    if !text.to_ascii_lowercase().contains("again") && !text.to_ascii_lowercase().contains("reset")
486    {
487        return None;
488    }
489    let (retry_at_ms, retry_label) = parse_retry_time(text, now_ms)?;
490    let kind = if has_pattern(
491        text,
492        &["rate limit reached", "rate limit exceeded", "rate limited"],
493    ) {
494        CliUsageLimitKind::Rate
495    } else if has_pattern(
496        text,
497        &[
498            "hit your usage limit",
499            "hit usage limit",
500            "usage limit reached",
501            "usage limit exceeded",
502            "quota reached",
503            "quota exceeded",
504            "limit reached",
505            "limit exceeded",
506            "reached your usage limit",
507            "exceeded your usage limit",
508        ],
509    ) {
510        CliUsageLimitKind::Usage
511    } else {
512        return None;
513    };
514    Some(CliUsageLimitState {
515        limited: true,
516        kind,
517        retry_at_ms,
518        retry_label,
519        retry_ready: now_ms >= retry_at_ms,
520    })
521}
522
523fn usage_limit_state_key(state: &CliUsageLimitState) -> String {
524    format!(
525        "{:?}:{}:{}",
526        state.kind, state.retry_at_ms, state.retry_label
527    )
528}
529
530static PRIMARY_FONT: LazyLock<StdMutex<Option<FontVec>>> = LazyLock::new(|| StdMutex::new(None));
531static CJK_FONT: LazyLock<StdMutex<Option<FontVec>>> = LazyLock::new(|| StdMutex::new(None));
532const FONT_SIZE: f32 = 14.0;
533const CELL_W: f32 = 8.4;
534const CELL_H: f32 = 18.0;
535const PADDING: u32 = 12;
536const BG_COLOR: Rgba<u8> = Rgba([26, 27, 38, 255]);
537const FG_COLOR: Rgba<u8> = Rgba([169, 177, 214, 255]);
538
539fn home_font_dir() -> Option<std::path::PathBuf> {
540    std::env::var("HOME")
541        .ok()
542        .map(|h| std::path::PathBuf::from(h).join(".beam").join("fonts"))
543}
544
545fn load_font_files() {
546    let mut primary = PRIMARY_FONT.lock().unwrap();
547    if primary.is_some() {
548        return;
549    }
550
551    let search_paths: Vec<std::path::PathBuf> = {
552        let mut paths = Vec::new();
553        if let Some(d) = home_font_dir() {
554            paths.push(d.join("JetBrainsMono-Regular.ttf"));
555            paths.push(d.join("DejaVuSansMono.ttf"));
556            paths.push(d.join("NotoSansMonoCJKsc-Regular.otf"));
557        }
558        paths.push("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf".into());
559        paths.push("/usr/share/fonts/dejavu/DejaVuSansMono.ttf".into());
560        paths.push("/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf".into());
561        paths.push("/usr/share/fonts/liberation/LiberationMono-Regular.ttf".into());
562        paths.push("/usr/share/fonts/truetype/jetbrains-mono/JetBrainsMono-Regular.ttf".into());
563        paths
564    };
565
566    for path in &search_paths {
567        if let Ok(data) = std::fs::read(path) {
568            if let Ok(font) = FontVec::try_from_vec(data) {
569                *primary = Some(font);
570                break;
571            }
572        }
573    }
574
575    let cjk_search: Vec<std::path::PathBuf> = {
576        let mut paths = Vec::new();
577        if let Some(d) = home_font_dir() {
578            paths.push(d.join("NotoSansMonoCJKsc-Regular.otf"));
579        }
580        paths.push("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc".into());
581        paths.push("/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc".into());
582        paths.push("/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc".into());
583        paths
584    };
585
586    let mut cjk = CJK_FONT.lock().unwrap();
587    for path in &cjk_search {
588        if let Ok(data) = std::fs::read(path) {
589            if let Ok(font) = FontVec::try_from_vec(data) {
590                *cjk = Some(font);
591                break;
592            }
593        }
594    }
595}
596
597fn is_fullwidth(ch: char) -> bool {
598    matches!(UnicodeWidthChar::width(ch), Some(2))
599}
600
601fn strip_ansi(s: &str) -> String {
602    let mut out = String::with_capacity(s.len());
603    let mut chars = s.chars().peekable();
604    while let Some(ch) = chars.next() {
605        if ch == '\x1b' {
606            if chars.peek() == Some(&'[') {
607                chars.next();
608                while let Some(&c) = chars.peek() {
609                    if c.is_ascii_alphanumeric() || c == ';' {
610                        chars.next();
611                        if c.is_ascii_alphabetic() {
612                            break;
613                        }
614                    } else {
615                        break;
616                    }
617                }
618            } else if chars.peek() == Some(&']') {
619                chars.next();
620                while let Some(&c) = chars.peek() {
621                    chars.next();
622                    if c == '\x07' || (c == '\x1b' && chars.peek() == Some(&'\\')) {
623                        if c == '\x1b' {
624                            chars.next();
625                        }
626                        break;
627                    }
628                }
629            }
630        } else {
631            out.push(ch);
632        }
633    }
634    out
635}
636
637fn find_glyph_font<'a>(
638    ch: char,
639    primary: &'a FontVec,
640    cjk: Option<&'a FontVec>,
641) -> (&'a FontVec, f32) {
642    let primary_id = primary.glyph_id(ch);
643    if primary_id.0 != 0 {
644        return (primary, 1.0);
645    }
646    if let Some(cjk_font) = cjk {
647        let cjk_id = cjk_font.glyph_id(ch);
648        if cjk_id.0 != 0 {
649            return (cjk_font, if is_fullwidth(ch) { 2.0 } else { 1.0 });
650        }
651    }
652    (primary, 1.0)
653}
654
655fn render_text_screenshot_png(screen_raw: &str) -> Result<Vec<u8>> {
656    load_font_files();
657
658    let screen = strip_ansi(screen_raw);
659    let lines: Vec<&str> = screen.lines().collect();
660    let rows = lines.len().max(1);
661
662    let primary_guard = PRIMARY_FONT.lock().unwrap();
663    let cjk_guard = CJK_FONT.lock().unwrap();
664    let primary = primary_guard.as_ref();
665    let cjk = cjk_guard.as_ref();
666
667    let primary = match primary {
668        Some(f) => f,
669        None => return fallback_bitmap_png(&screen),
670    };
671
672    let scale = PxScale::from(FONT_SIZE);
673    let scaled = primary.as_scaled(scale);
674    let baseline_offset =
675        ((CELL_H - (scaled.ascent() + scaled.descent())).max(0.0) / 2.0) + scaled.ascent();
676
677    let cols = lines
678        .iter()
679        .map(|line| {
680            line.chars()
681                .map(|ch| if is_fullwidth(ch) { 2u32 } else { 1u32 })
682                .sum::<u32>()
683        })
684        .max()
685        .unwrap_or(1)
686        .max(1);
687
688    let width = ((cols as f32 * CELL_W).ceil() as u32 + PADDING * 2).clamp(64, 2200);
689    let height = ((rows as f32 * CELL_H).ceil() as u32 + PADDING * 2).clamp(32, 2200);
690
691    let mut image = ImageBuffer::from_pixel(width, height, BG_COLOR);
692
693    for (row, line) in lines.iter().enumerate() {
694        let mut col_cells: u32 = 0;
695        for ch in line.chars() {
696            let (font, char_width) = find_glyph_font(ch, primary, cjk);
697            let scaled = font.as_scaled(scale);
698            let x = PADDING as f32 + col_cells as f32 * CELL_W;
699            let y = PADDING as f32 + row as f32 * CELL_H;
700
701            if ch != ' ' {
702                let cell_px = char_width * CELL_W;
703                let advance = scaled.h_advance(scaled.glyph_id(ch));
704                let glyph_x = x + ((cell_px - advance).max(0.0) / 2.0);
705                let baseline = y + baseline_offset;
706                let mut glyph = scaled.scaled_glyph(ch);
707                glyph.position = point(glyph_x, baseline);
708                if let Some(outline) = font.outline_glyph(glyph) {
709                    let bounds = outline.px_bounds();
710                    outline.draw(|gx, gy, cv| {
711                        let px = bounds.min.x as i32 + gx as i32;
712                        let py = bounds.min.y as i32 + gy as i32;
713                        if px >= 0
714                            && py >= 0
715                            && (px as u32) < width
716                            && (py as u32) < height
717                            && cv > 0.0
718                        {
719                            let alpha = (cv * 255.0).min(255.0) as u8;
720                            if alpha == 255 {
721                                image.put_pixel(px as u32, py as u32, FG_COLOR);
722                            } else {
723                                let existing = image.get_pixel(px as u32, py as u32);
724                                let blended = blend_alpha(*existing, FG_COLOR, alpha);
725                                image.put_pixel(px as u32, py as u32, blended);
726                            }
727                        }
728                    });
729                }
730            }
731
732            col_cells += char_width.ceil() as u32;
733        }
734    }
735
736    let mut out = Vec::new();
737    let encoder = PngEncoder::new(&mut out);
738    encoder.write_image(image.as_raw(), width, height, ColorType::Rgba8.into())?;
739    Ok(out)
740}
741
742fn blend_alpha(bg: Rgba<u8>, fg: Rgba<u8>, alpha: u8) -> Rgba<u8> {
743    let a = alpha as f32 / 255.0;
744    let r = (fg.0[0] as f32 * a + bg.0[0] as f32 * (1.0 - a)) as u8;
745    let g = (fg.0[1] as f32 * a + bg.0[1] as f32 * (1.0 - a)) as u8;
746    let b = (fg.0[2] as f32 * a + bg.0[2] as f32 * (1.0 - a)) as u8;
747    Rgba([r, g, b, 255])
748}
749
750fn fallback_bitmap_png(screen: &str) -> Result<Vec<u8>> {
751    use font8x8::UnicodeFonts;
752
753    let lines: Vec<&str> = screen.lines().collect();
754    let rows = lines.len().max(1);
755    let cols = lines
756        .iter()
757        .map(|line| line.chars().count())
758        .max()
759        .unwrap_or(1)
760        .max(1);
761    let scale = 2u32;
762    let glyph_w = 8u32 * scale;
763    let glyph_h = 8u32 * scale;
764    let width = (cols as u32 * glyph_w + PADDING * 2).clamp(64, 2200);
765    let height = (rows as u32 * glyph_h + PADDING * 2).clamp(32, 2200);
766    let bg = Rgba([15, 23, 42, 255]);
767    let fg = Rgba([226, 232, 240, 255]);
768    let mut image = ImageBuffer::from_pixel(width, height, bg);
769
770    for (row, line) in lines.iter().enumerate() {
771        for (col, ch) in line.chars().take(cols as usize).enumerate() {
772            let glyph = font8x8::BASIC_FONTS
773                .get(ch)
774                .or_else(|| font8x8::BASIC_FONTS.get('?'))
775                .unwrap_or([0; 8]);
776            for (gy, bits) in glyph.iter().enumerate() {
777                for gx in 0..8 {
778                    if (bits >> gx) & 1 == 0 {
779                        continue;
780                    }
781                    for sy in 0..scale {
782                        for sx in 0..scale {
783                            let x = PADDING + col as u32 * glyph_w + (7 - gx) as u32 * scale + sx;
784                            let y = PADDING + row as u32 * glyph_h + gy as u32 * scale + sy;
785                            if x < width && y < height {
786                                image.put_pixel(x, y, fg);
787                            }
788                        }
789                    }
790                }
791            }
792        }
793    }
794
795    let mut out = Vec::new();
796    let encoder = PngEncoder::new(&mut out);
797    encoder.write_image(image.as_raw(), width, height, ColorType::Rgba8.into())?;
798    Ok(out)
799}
800
801fn lark_base_url() -> &'static str {
802    "https://open.feishu.cn/open-apis"
803}
804
805async fn lark_tenant_token(app_id: &str, secret: &str) -> Result<String> {
806    let body = reqwest::Client::new()
807        .post(format!(
808            "{}/auth/v3/tenant_access_token/internal",
809            lark_base_url()
810        ))
811        .json(&serde_json::json!({
812            "app_id": app_id,
813            "app_secret": secret,
814        }))
815        .send()
816        .await?
817        .json::<LarkTokenResponse>()
818        .await?;
819    if body.code != 0 {
820        anyhow::bail!(
821            "lark tenant_access_token failed: {}",
822            body.msg.unwrap_or_else(|| "unknown error".to_string())
823        );
824    }
825    body.tenant_access_token
826        .context("lark tenant_access_token missing")
827}
828
829async fn upload_image_buffer(app_id: &str, secret: &str, image: Vec<u8>) -> Result<String> {
830    let token = lark_tenant_token(app_id, secret).await?;
831    let form = Form::new().text("image_type", "message").part(
832        "image",
833        Part::bytes(image)
834            .file_name("screen.png")
835            .mime_str("image/png")?,
836    );
837    let body = reqwest::Client::new()
838        .post(format!("{}/im/v1/images", lark_base_url()))
839        .bearer_auth(token)
840        .multipart(form)
841        .send()
842        .await?
843        .json::<LarkImageUploadResponse>()
844        .await?;
845    if body.code != 0 {
846        anyhow::bail!(
847            "lark image upload failed: {}",
848            body.msg.unwrap_or_else(|| "unknown error".to_string())
849        );
850    }
851    body.image_key
852        .or_else(|| body.data.and_then(|data| data.image_key))
853        .context("lark image upload missing image_key")
854}
855
856async fn maybe_send_screenshot_upload(
857    stdout: &Arc<Mutex<tokio::io::Stdout>>,
858    app_id: &str,
859    app_secret: &str,
860    screen: &str,
861    status: ScreenStatus,
862    usage_limit: Option<CliUsageLimitState>,
863    last_uploaded_hash: &Arc<Mutex<Option<String>>>,
864) {
865    if app_id == "local" || app_secret.is_empty() {
866        return;
867    }
868    let card_screen = card_viewport_text(screen);
869    let hash = format!("{:x}", Sha256::digest(card_screen.as_bytes()));
870    {
871        let guard = last_uploaded_hash.lock().await;
872        if guard.as_deref() == Some(hash.as_str()) {
873            return;
874        }
875    }
876    let png = match render_text_screenshot_png(&card_screen) {
877        Ok(png) => png,
878        Err(err) => {
879            warn!("failed to render terminal screenshot: {err:#}");
880            return;
881        }
882    };
883    let image_key = match upload_image_buffer(app_id, app_secret, png).await {
884        Ok(image_key) => image_key,
885        Err(err) => {
886            warn!("failed to upload terminal screenshot: {err:#}");
887            return;
888        }
889    };
890    let _ = send_message(
891        stdout,
892        &WorkerToDaemon::ScreenshotUploaded {
893            image_key,
894            status,
895            usage_limit,
896        },
897    )
898    .await;
899    *last_uploaded_hash.lock().await = Some(hash);
900}
901
902#[derive(Debug, Default)]
903struct UsageLimitTracker {
904    turn_seq: u64,
905    detected_turn: Option<u64>,
906    suppressed_retry_ready_key: Option<String>,
907}
908
909impl UsageLimitTracker {
910    fn begin_turn(&mut self, snapshot: &str, now_ms: u64) -> u64 {
911        self.turn_seq += 1;
912        self.detected_turn = None;
913        self.suppressed_retry_ready_key = detect_cli_usage_limit(snapshot, now_ms)
914            .filter(|state| state.retry_ready)
915            .map(|state| usage_limit_state_key(&state));
916        self.turn_seq
917    }
918
919    fn classify(
920        &mut self,
921        content: &str,
922        status: ScreenStatus,
923        now_ms: u64,
924    ) -> (ScreenStatus, Option<CliUsageLimitState>) {
925        let Some(detected) = detect_cli_usage_limit(content, now_ms) else {
926            return (status, None);
927        };
928        let key = usage_limit_state_key(&detected);
929        if detected.retry_ready && self.suppressed_retry_ready_key.as_deref() == Some(key.as_str())
930        {
931            return (status, None);
932        }
933        self.suppressed_retry_ready_key = None;
934        self.detected_turn = Some(self.turn_seq);
935        (ScreenStatus::Limited, Some(detected))
936    }
937}
938
939async fn prepare_wrapper(init: &InitConfig, paths: &BeamPaths) -> Result<std::path::PathBuf> {
940    tokio::fs::create_dir_all(paths.run_dir()).await?;
941    let wrapper = paths.worker_wrapper_sh(&init.session_id);
942    let exe_path = std::env::current_exe()
943        .ok()
944        .map(|p| p.display().to_string())
945        .unwrap_or_else(|| "beam".to_string());
946    let content = format!(
947        "#!/bin/sh\ncd {cwd}\nexport BEAM_SESSION_ID={sid}\nexport BEAM_HOME={home}\nexport BEAM_BIN={exe}\nif [ -n \"$PATH\" ]; then\n  export PATH={bindir}:$PATH\nelse\n  export PATH={bindir}\nfi\nexec \"$@\"\n",
948        cwd = shell_quote(&init.working_dir),
949        sid = shell_quote(&init.session_id),
950        home = shell_quote(&paths.root().display().to_string()),
951        exe = shell_quote(&exe_path),
952        bindir = shell_quote(
953            &std::env::current_exe()
954                .ok()
955                .and_then(|p| p.parent().map(|v| v.display().to_string()))
956                .unwrap_or_default()
957        ),
958    );
959    tokio::fs::write(&wrapper, content).await?;
960    #[cfg(unix)]
961    {
962        use std::os::unix::fs::PermissionsExt;
963        let perms = std::fs::Permissions::from_mode(0o755);
964        tokio::fs::set_permissions(&wrapper, perms).await?;
965    }
966    Ok(wrapper)
967}
968
969async fn send_message(stdout: &Arc<Mutex<tokio::io::Stdout>>, msg: &WorkerToDaemon) -> Result<()> {
970    let mut out = stdout.lock().await;
971    out.write_all(serde_json::to_string(msg)?.as_bytes())
972        .await?;
973    out.write_all(b"\n").await?;
974    out.flush().await?;
975    Ok(())
976}
977
978async fn handle_tui_keys(
979    backend: &Arc<Mutex<Box<dyn SessionBackend>>>,
980    analyzer_runtime: &Arc<RwLock<AnalyzerRuntime>>,
981    keys: &[String],
982    is_final: bool,
983) -> Result<()> {
984    if keys.is_empty() {
985        return Ok(());
986    }
987    let guard = backend.lock().await;
988    for key in keys {
989        guard.send_special_keys(std::slice::from_ref(key)).await?;
990        tokio::time::sleep(Duration::from_millis(100)).await;
991    }
992    drop(guard);
993    if is_final {
994        analyzer_runtime.write().await.prompt_active = false;
995    }
996    Ok(())
997}
998
999async fn handle_tui_text_input(
1000    backend: &Arc<Mutex<Box<dyn SessionBackend>>>,
1001    adapter: &Arc<Mutex<CliAdapter>>,
1002    analyzer_runtime: &Arc<RwLock<AnalyzerRuntime>>,
1003    keys: &[String],
1004    text: &str,
1005) -> Result<()> {
1006    let nav_keys = if keys.last().map(String::as_str) == Some("Enter") {
1007        &keys[..keys.len().saturating_sub(1)]
1008    } else {
1009        keys
1010    };
1011    if !nav_keys.is_empty() {
1012        let guard = backend.lock().await;
1013        for key in nav_keys {
1014            guard.send_special_keys(std::slice::from_ref(key)).await?;
1015            tokio::time::sleep(Duration::from_millis(100)).await;
1016        }
1017    }
1018    analyzer_runtime.write().await.prompt_active = false;
1019    tokio::time::sleep(Duration::from_millis(200)).await;
1020    let guard = backend.lock().await;
1021    let _ = adapter
1022        .lock()
1023        .await
1024        .write_input(guard.as_ref(), text)
1025        .await?;
1026    Ok(())
1027}
1028
1029async fn handle_tui_prompt_override(
1030    stdout: &Arc<Mutex<tokio::io::Stdout>>,
1031    analyzer_runtime: &Arc<RwLock<AnalyzerRuntime>>,
1032) {
1033    let was_active = {
1034        let mut runtime = analyzer_runtime.write().await;
1035        if runtime.prompt_active {
1036            runtime.prompt_active = false;
1037            true
1038        } else {
1039            false
1040        }
1041    };
1042    if was_active {
1043        let _ = send_message(
1044            stdout,
1045            &WorkerToDaemon::TuiPromptResolved {
1046                selected_text: Some("user-override".to_string()),
1047            },
1048        )
1049        .await;
1050    }
1051}
1052
1053pub async fn run(init: InitConfig) -> Result<()> {
1054    let stdout = Arc::new(Mutex::new(tokio::io::stdout()));
1055    let session_name = format!("bmx-{}", &init.session_id[..8.min(init.session_id.len())]);
1056    let paths = BeamPaths::discover()?;
1057    let adapter = Arc::new(Mutex::new(CliAdapter::from_init(&init)?));
1058    let wrapper = if init.resume || init.adopted_from.is_some() {
1059        None
1060    } else {
1061        Some(prepare_wrapper(&init, &paths).await?)
1062    };
1063    let (mut backend_impl, attach_context): (Box<dyn SessionBackend>, &'static str) =
1064        if let Some(adopted) = init.adopted_from.as_ref() {
1065            if let Some(pane_id) = adopted.zellij_pane_id.clone() {
1066                let session = adopted.zellij_session.clone().unwrap_or_else(|| {
1067                    format!("bmx-{}", &init.session_id[..8.min(init.session_id.len())])
1068                });
1069                let observe = ZellijObserveBackend::new(
1070                    session,
1071                    pane_id,
1072                    u32::try_from(adopted.original_cli_pid).ok(),
1073                );
1074                (Box::new(observe), "observe")
1075            } else {
1076                let zellij = ZellijBackend::new(session_name.clone());
1077                (Box::new(zellij), "spawn")
1078            }
1079        } else {
1080            let zellij = ZellijBackend::new(session_name.clone());
1081            (Box::new(zellij), "spawn")
1082        };
1083    let spawn_spec = adapter.lock().await.build_spawn_spec(&init);
1084    let args = if let Some(wrapper) = wrapper {
1085        let mut args = Vec::with_capacity(2 + init.cli_args.len());
1086        args.push(wrapper.display().to_string());
1087        args.push(spawn_spec.bin.clone());
1088        args.extend(spawn_spec.args.clone());
1089        ("/bin/sh".to_string(), args)
1090    } else {
1091        (spawn_spec.bin, spawn_spec.args)
1092    };
1093    backend_impl
1094        .spawn(
1095            &args.0,
1096            &args.1,
1097            SpawnOpts {
1098                cwd: init.working_dir.clone(),
1099                cols: FALLBACK_TERMINAL_COLS,
1100                rows: FALLBACK_TERMINAL_ROWS,
1101                env: Vec::new(),
1102            },
1103        )
1104        .await
1105        .with_context(|| format!("failed to {} session {}", attach_context, init.session_id))?;
1106
1107    let backend: Arc<Mutex<Box<dyn SessionBackend>>> = Arc::new(Mutex::new(backend_impl));
1108    let mut cli_pid_marker = None;
1109    let child_pid = backend.lock().await.child_pid().await?;
1110    adapter.lock().await.on_spawned(child_pid);
1111    if let Some(pid) = child_pid {
1112        tokio::fs::create_dir_all(paths.cli_pid_markers_dir()).await?;
1113        let marker = paths.cli_pid_markers_dir().join(pid.to_string());
1114        tokio::fs::write(&marker, init.session_id.as_bytes()).await?;
1115        cli_pid_marker = Some(marker);
1116    }
1117    let latest_screen = Arc::new(RwLock::new(String::new()));
1118    let latest_raw_screen = Arc::new(RwLock::new(String::new()));
1119    let display_mode = Arc::new(RwLock::new(DisplayMode::Hidden));
1120    let analyzer_runtime = Arc::new(RwLock::new(AnalyzerRuntime::default()));
1121    let usage_limit_tracker = Arc::new(Mutex::new(UsageLimitTracker::default()));
1122    let current_turn_id = Arc::new(RwLock::new(String::new()));
1123    let (updates, _) = broadcast::channel::<String>(256);
1124
1125    send_message(
1126        &stdout,
1127        &WorkerToDaemon::Ready {
1128            zellij_session: session_name.clone(),
1129        },
1130    )
1131    .await?;
1132
1133    let sample_backend = backend.clone();
1134    let sample_screen = latest_screen.clone();
1135    let sample_raw_screen = latest_raw_screen.clone();
1136    let sample_updates = updates.clone();
1137    let sample_stdout = stdout.clone();
1138    let sample_adapter = adapter.clone();
1139    let sample_display_mode = display_mode.clone();
1140    let sample_usage_limit_tracker = usage_limit_tracker.clone();
1141    let sample_current_turn_id = current_turn_id.clone();
1142    let screenshot_app_id = init.lark_app_id.clone();
1143    let screenshot_app_secret = init.lark_app_secret.clone();
1144    let last_uploaded_hash = Arc::new(Mutex::new(None::<String>));
1145    let sample_last_uploaded_hash = last_uploaded_hash.clone();
1146    let last_broadcast_hash: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
1147    let sample_last_broadcast_hash = last_broadcast_hash.clone();
1148    let sample_analyzer_runtime = analyzer_runtime.clone();
1149    let screen_capture_task = tokio::spawn(async move {
1150        let mut last_emitted_status = ScreenStatus::Starting;
1151        let mut last_emitted_usage_limit: Option<CliUsageLimitState> = None;
1152        loop {
1153            let (screen, alive) = {
1154                let guard = sample_backend.lock().await;
1155                let screen = guard.capture_viewport().await.unwrap_or_default();
1156                let alive = guard.is_alive().await.unwrap_or(false);
1157                (screen, alive)
1158            };
1159
1160            let hash_changed;
1161
1162            {
1163                *sample_raw_screen.write().await = screen.clone();
1164                let mode = *sample_display_mode.read().await;
1165                let rendered = render_screen_for_display_mode(&screen, mode);
1166                let now_ms = now_ms();
1167                let analyzing = sample_analyzer_runtime.read().await.is_analyzing;
1168                let base_status = if analyzing {
1169                    ScreenStatus::Analyzing
1170                } else {
1171                    ScreenStatus::Working
1172                };
1173                let (status, usage_limit) =
1174                    sample_usage_limit_tracker
1175                        .lock()
1176                        .await
1177                        .classify(&screen, base_status, now_ms);
1178                let rendered_hash = format!("{:x}", Sha256::digest(rendered.as_bytes()));
1179                {
1180                    let guard = sample_last_broadcast_hash.lock().await;
1181                    hash_changed = guard.as_deref() != Some(&rendered_hash);
1182                }
1183
1184                if hash_changed
1185                    || last_emitted_status != status
1186                    || last_emitted_usage_limit != usage_limit
1187                {
1188                    *sample_last_broadcast_hash.lock().await = Some(rendered_hash.clone());
1189                    let mut current = sample_screen.write().await;
1190                    *current = rendered.clone();
1191                    let _ = sample_updates.send(rendered.clone());
1192                    let _ = send_message(
1193                        &sample_stdout,
1194                        &WorkerToDaemon::ScreenUpdate {
1195                            content: rendered.clone(),
1196                            status,
1197                            usage_limit: usage_limit.clone(),
1198                        },
1199                    )
1200                    .await;
1201                    last_emitted_status = status;
1202                    last_emitted_usage_limit = usage_limit.clone();
1203                }
1204
1205                if mode == DisplayMode::Screenshot
1206                    && screenshot_app_id != "local"
1207                    && !screenshot_app_secret.is_empty()
1208                {
1209                    maybe_send_screenshot_upload(
1210                        &sample_stdout,
1211                        &screenshot_app_id,
1212                        &screenshot_app_secret,
1213                        &screen,
1214                        status,
1215                        usage_limit.clone(),
1216                        &sample_last_uploaded_hash,
1217                    )
1218                    .await;
1219                }
1220            }
1221
1222            if let Ok(poll) = sample_adapter.lock().await.poll() {
1223                if let Some(cli_session_id) = poll.cli_session_id {
1224                    let _ = send_message(
1225                        &sample_stdout,
1226                        &WorkerToDaemon::CliSessionId { cli_session_id },
1227                    )
1228                    .await;
1229                }
1230                if let Some((user_text, assistant_text)) = poll.adopt_preamble {
1231                    let _ = send_message(
1232                        &sample_stdout,
1233                        &WorkerToDaemon::AdoptPreamble {
1234                            user_text,
1235                            assistant_text,
1236                        },
1237                    )
1238                    .await;
1239                }
1240                if let Some(content) = poll.final_output {
1241                    let turn_id = sample_current_turn_id.read().await.clone();
1242                    let _ = send_message(
1243                        &sample_stdout,
1244                        &WorkerToDaemon::FinalOutput {
1245                            content,
1246                            turn_id,
1247                            kind: poll.final_output_kind,
1248                            user_text: poll.final_output_user_text,
1249                        },
1250                    )
1251                    .await;
1252                }
1253                if poll.prompt_ready {
1254                    let _ = send_message(&sample_stdout, &WorkerToDaemon::PromptReady).await;
1255                    let rendered = sample_screen.read().await.clone();
1256                    let raw = sample_raw_screen.read().await.clone();
1257                    let now_ms = now_ms();
1258                    let analyzing = sample_analyzer_runtime.read().await.is_analyzing;
1259                    let base_status = if analyzing {
1260                        ScreenStatus::Analyzing
1261                    } else {
1262                        ScreenStatus::Idle
1263                    };
1264                    let (status, usage_limit) =
1265                        sample_usage_limit_tracker
1266                            .lock()
1267                            .await
1268                            .classify(&raw, base_status, now_ms);
1269                    let _ = send_message(
1270                        &sample_stdout,
1271                        &WorkerToDaemon::ScreenUpdate {
1272                            content: rendered,
1273                            status,
1274                            usage_limit,
1275                        },
1276                    )
1277                    .await;
1278                }
1279            }
1280
1281            if !alive {
1282                let _ = send_message(
1283                    &sample_stdout,
1284                    &WorkerToDaemon::CliExit {
1285                        code: Some(0),
1286                        signal: None,
1287                    },
1288                )
1289                .await;
1290                break;
1291            }
1292
1293            tokio::time::sleep(Duration::from_millis(5000)).await;
1294        }
1295    });
1296    let mut worker_joins = tokio::task::JoinSet::new();
1297    worker_joins.spawn(async move {
1298        let _ = screen_capture_task.await;
1299    });
1300
1301    if screen_analyzer_enabled(&init.screen_analyzer) {
1302        let analyzer_cfg = init.screen_analyzer.clone();
1303        let analyzer_raw_screen = latest_raw_screen.clone();
1304        let analyzer_runtime_state = analyzer_runtime.clone();
1305        let analyzer_stdout = stdout.clone();
1306        let analyzer_task = tokio::spawn(async move {
1307            let client = Client::new();
1308            loop {
1309                tokio::time::sleep(Duration::from_millis(analyzer_cfg.interval_ms)).await;
1310                let snapshot = analyzer_raw_screen.read().await.clone();
1311                if snapshot.is_empty() {
1312                    continue;
1313                }
1314                let truncated = if snapshot.len() > analyzer_cfg.snapshot_max_chars {
1315                    snapshot[snapshot.len() - analyzer_cfg.snapshot_max_chars..].to_string()
1316                } else {
1317                    snapshot
1318                };
1319                let now = now_ms();
1320                {
1321                    let mut runtime = analyzer_runtime_state.write().await;
1322                    if truncated == runtime.last_snapshot {
1323                        runtime.stable_count = runtime.stable_count.saturating_add(1);
1324                    } else {
1325                        runtime.stable_count = 1;
1326                        runtime.last_snapshot = truncated.clone();
1327                        if runtime.waiting_for_content_change {
1328                            runtime.waiting_for_content_change = false;
1329                        }
1330                    }
1331                    if runtime.stable_count < analyzer_cfg.stable_count {
1332                        continue;
1333                    }
1334                    if runtime.waiting_for_content_change
1335                        && truncated == runtime.last_analyzed_snapshot
1336                    {
1337                        continue;
1338                    }
1339                    if runtime.cooldown_until_ms > now {
1340                        continue;
1341                    }
1342                    runtime.is_analyzing = true;
1343                    runtime.last_analyzed_snapshot = truncated.clone();
1344                }
1345
1346                let result = call_screen_analyzer(&client, &analyzer_cfg, &truncated).await;
1347
1348                let mut runtime = analyzer_runtime_state.write().await;
1349                runtime.is_analyzing = false;
1350                match result {
1351                    Ok(analysis) => {
1352                        apply_screen_analyzer_result(
1353                            &mut runtime,
1354                            &analysis.check_again_when,
1355                            now_ms(),
1356                        );
1357                        if analysis.needs_interaction && !analysis.options.is_empty() {
1358                            if !runtime.prompt_active {
1359                                runtime.prompt_active = true;
1360                                let _ = send_message(
1361                                    &analyzer_stdout,
1362                                    &WorkerToDaemon::TuiPrompt {
1363                                        description: analysis.description.clone().unwrap_or_else(
1364                                            || "CLI needs your selection".to_string(),
1365                                        ),
1366                                        options: analysis.options.clone(),
1367                                        multi_select: analysis.multi_select,
1368                                    },
1369                                )
1370                                .await;
1371                            }
1372                        } else if runtime.prompt_active {
1373                            runtime.prompt_active = false;
1374                            let _ = send_message(
1375                                &analyzer_stdout,
1376                                &WorkerToDaemon::TuiPromptResolved {
1377                                    selected_text: None,
1378                                },
1379                            )
1380                            .await;
1381                        }
1382                    }
1383                    Err(_) => {
1384                        runtime.waiting_for_content_change = true;
1385                        runtime.cooldown_until_ms = 0;
1386                    }
1387                }
1388            }
1389        });
1390        worker_joins.spawn(async move {
1391            let _ = analyzer_task.await;
1392        });
1393    }
1394
1395    if !init.prompt.is_empty() && !crate::adapters::passes_initial_prompt_via_args(&init.cli_id) {
1396        usage_limit_tracker.lock().await.begin_turn(
1397            "",
1398            SystemTime::now()
1399                .duration_since(UNIX_EPOCH)
1400                .unwrap_or_default()
1401                .as_millis() as u64,
1402        );
1403        *current_turn_id.write().await = init
1404            .prompt_turn_id
1405            .clone()
1406            .unwrap_or_else(|| Uuid::new_v4().to_string());
1407        *last_uploaded_hash.lock().await = None;
1408        let guard = backend.lock().await;
1409        let submit = adapter
1410            .lock()
1411            .await
1412            .write_input(guard.as_ref(), &init.prompt)
1413            .await?;
1414        if let Some(cli_session_id) = submit.cli_session_id {
1415            send_message(&stdout, &WorkerToDaemon::CliSessionId { cli_session_id }).await?;
1416        }
1417    }
1418
1419    let stdin = BufReader::new(tokio::io::stdin());
1420    let mut lines = stdin.lines();
1421    loop {
1422        let line = match lines.next_line().await {
1423            Ok(Some(line)) => line,
1424            Ok(None) => break,
1425            Err(_) => break,
1426        };
1427        if line.trim().is_empty() {
1428            continue;
1429        }
1430        let msg: DaemonToWorker = serde_json::from_str(&line)?;
1431        match msg {
1432            DaemonToWorker::Message { content, turn_id } => {
1433                handle_tui_prompt_override(&stdout, &analyzer_runtime).await;
1434                let snapshot = latest_raw_screen.read().await.clone();
1435                usage_limit_tracker.lock().await.begin_turn(
1436                    &snapshot,
1437                    SystemTime::now()
1438                        .duration_since(UNIX_EPOCH)
1439                        .unwrap_or_default()
1440                        .as_millis() as u64,
1441                );
1442                *current_turn_id.write().await = turn_id;
1443                *last_uploaded_hash.lock().await = None;
1444                let guard = backend.lock().await;
1445                let submit = adapter
1446                    .lock()
1447                    .await
1448                    .write_input(guard.as_ref(), &content)
1449                    .await?;
1450                if let Some(cli_session_id) = submit.cli_session_id {
1451                    send_message(&stdout, &WorkerToDaemon::CliSessionId { cli_session_id }).await?;
1452                }
1453                if !submit.submitted {
1454                    let message = submit
1455                        .failure_reason
1456                        .unwrap_or_else(|| "CLI submit could not be confirmed".to_string());
1457                    send_message(&stdout, &WorkerToDaemon::UserNotify { message }).await?;
1458                }
1459            }
1460            DaemonToWorker::RawInput { content, turn_id } => {
1461                handle_tui_prompt_override(&stdout, &analyzer_runtime).await;
1462                let snapshot = latest_raw_screen.read().await.clone();
1463                usage_limit_tracker.lock().await.begin_turn(
1464                    &snapshot,
1465                    SystemTime::now()
1466                        .duration_since(UNIX_EPOCH)
1467                        .unwrap_or_default()
1468                        .as_millis() as u64,
1469                );
1470                *current_turn_id.write().await = turn_id;
1471                *last_uploaded_hash.lock().await = None;
1472                let guard = backend.lock().await;
1473                guard.raw_input(&content).await?;
1474            }
1475            DaemonToWorker::Close => {
1476                let mut guard = backend.lock().await;
1477                guard.destroy_session().await?;
1478                break;
1479            }
1480            DaemonToWorker::Restart => {
1481                let mut guard = backend.lock().await;
1482                guard.destroy_session().await?;
1483                break;
1484            }
1485            DaemonToWorker::RefreshScreen => {
1486                let guard = backend.lock().await;
1487                let screen = guard.capture_viewport().await?;
1488                *latest_raw_screen.write().await = screen.clone();
1489                let mode = *display_mode.read().await;
1490                let rendered = render_screen_for_display_mode(&screen, mode);
1491                let now_ms = now_ms();
1492                let analyzing = analyzer_runtime.read().await.is_analyzing;
1493                let base_status = if analyzing {
1494                    ScreenStatus::Analyzing
1495                } else {
1496                    ScreenStatus::Working
1497                };
1498                let (status, usage_limit) =
1499                    usage_limit_tracker
1500                        .lock()
1501                        .await
1502                        .classify(&screen, base_status, now_ms);
1503                *latest_screen.write().await = rendered.clone();
1504                let _ = updates.send(rendered.clone());
1505                send_message(
1506                    &stdout,
1507                    &WorkerToDaemon::ScreenUpdate {
1508                        content: rendered.clone(),
1509                        status,
1510                        usage_limit: usage_limit.clone(),
1511                    },
1512                )
1513                .await?;
1514                let rendered_hash = format!("{:x}", Sha256::digest(rendered.as_bytes()));
1515                *last_broadcast_hash.lock().await = Some(rendered_hash);
1516                if mode == DisplayMode::Screenshot {
1517                    maybe_send_screenshot_upload(
1518                        &stdout,
1519                        &init.lark_app_id,
1520                        &init.lark_app_secret,
1521                        &screen,
1522                        status,
1523                        usage_limit,
1524                        &last_uploaded_hash,
1525                    )
1526                    .await;
1527                }
1528            }
1529            DaemonToWorker::SetDisplayMode { mode } => {
1530                *display_mode.write().await = mode;
1531                let raw = latest_raw_screen.read().await.clone();
1532                let rendered = render_screen_for_display_mode(&raw, mode);
1533                let now_ms = now_ms();
1534                let analyzing = analyzer_runtime.read().await.is_analyzing;
1535                let base_status = if analyzing {
1536                    ScreenStatus::Analyzing
1537                } else {
1538                    ScreenStatus::Working
1539                };
1540                let (status, usage_limit) =
1541                    usage_limit_tracker
1542                        .lock()
1543                        .await
1544                        .classify(&raw, base_status, now_ms);
1545                *latest_screen.write().await = rendered.clone();
1546                let _ = updates.send(rendered.clone());
1547                send_message(
1548                    &stdout,
1549                    &WorkerToDaemon::ScreenUpdate {
1550                        content: rendered,
1551                        status,
1552                        usage_limit: usage_limit.clone(),
1553                    },
1554                )
1555                .await?;
1556                if mode == DisplayMode::Screenshot {
1557                    maybe_send_screenshot_upload(
1558                        &stdout,
1559                        &init.lark_app_id,
1560                        &init.lark_app_secret,
1561                        &raw,
1562                        status,
1563                        usage_limit,
1564                        &last_uploaded_hash,
1565                    )
1566                    .await;
1567                }
1568            }
1569            DaemonToWorker::TermAction { key } => {
1570                let keys = term_action_keys(key);
1571                let guard = backend.lock().await;
1572                guard.send_special_keys(&keys).await?;
1573            }
1574            DaemonToWorker::SpecialKeys { keys } => {
1575                let guard = backend.lock().await;
1576                guard.send_special_keys(&keys).await?;
1577            }
1578            DaemonToWorker::TuiKeys { keys, is_final } => {
1579                handle_tui_keys(&backend, &analyzer_runtime, &keys, is_final).await?;
1580            }
1581            DaemonToWorker::TuiTextInput { keys, text } => {
1582                handle_tui_text_input(&backend, &adapter, &analyzer_runtime, &keys, &text).await?;
1583            }
1584            DaemonToWorker::Init(_) => {}
1585        }
1586    }
1587
1588    worker_joins.abort_all();
1589    while worker_joins.join_next().await.is_some() {}
1590    {
1591        let mut guard = backend.lock().await;
1592        let _ = guard.kill().await;
1593    }
1594    if let Some(marker) = cli_pid_marker {
1595        let _ = tokio::fs::remove_file(marker).await;
1596    }
1597
1598    info!("worker exiting");
1599    Ok(())
1600}
1601
1602#[cfg(test)]
1603mod tests {
1604    use super::*;
1605
1606    #[test]
1607    fn render_screen_for_display_mode_hides_or_shows_content() {
1608        assert_eq!(
1609            render_screen_for_display_mode("hello", DisplayMode::Hidden),
1610            "[screen hidden]"
1611        );
1612        assert_eq!(
1613            render_screen_for_display_mode("hello", DisplayMode::Screenshot),
1614            "hello"
1615        );
1616    }
1617
1618    #[test]
1619    fn card_viewport_text_crops_to_card_dimensions() {
1620        let screen = (0..40)
1621            .map(|idx| format!("{idx:02}:{}", "x".repeat(140)))
1622            .collect::<Vec<_>>()
1623            .join("\n");
1624        let cropped = card_viewport_text(&screen);
1625        let lines: Vec<&str> = cropped.lines().collect();
1626
1627        assert_eq!(lines.len(), CARD_VIEWPORT_ROWS);
1628        assert!(
1629            lines
1630                .iter()
1631                .all(|line| line.chars().count() <= CARD_VIEWPORT_COLS)
1632        );
1633    }
1634
1635    #[test]
1636    fn card_viewport_text_strips_ansi_and_respects_fullwidth_cells() {
1637        let screen = format!("\x1b[31m{}\x1b[0m", "好".repeat(80));
1638        let cropped = card_viewport_text(&screen);
1639
1640        assert_eq!(cropped.chars().count(), CARD_VIEWPORT_COLS / 2);
1641        assert!(!cropped.contains("\x1b[31m"));
1642    }
1643
1644    #[test]
1645    fn term_action_keys_maps_supported_actions() {
1646        assert_eq!(term_action_keys(TermActionKey::Esc), vec!["Escape"]);
1647        assert_eq!(term_action_keys(TermActionKey::CtrlC), vec!["C-c"]);
1648        assert_eq!(term_action_keys(TermActionKey::Enter), vec!["Enter"]);
1649        assert_eq!(
1650            term_action_keys(TermActionKey::HalfPageDown),
1651            vec!["PageDown"]
1652        );
1653    }
1654
1655    #[test]
1656    fn detect_cli_usage_limit_finds_usage_and_rate_limits() {
1657        let usage = detect_cli_usage_limit(
1658            "You have hit your usage limit. Try again at 3:15 PM.",
1659            1_700_000_000_000,
1660        )
1661        .expect("usage limit detected");
1662        assert_eq!(usage.kind, CliUsageLimitKind::Usage);
1663        assert_eq!(usage.retry_label, "3:15 PM");
1664        assert!(usage.limited);
1665
1666        let rate = detect_cli_usage_limit("Rate limited. Resets at 11:00 AM.", 1_700_000_000_000)
1667            .expect("rate limit detected");
1668        assert_eq!(rate.kind, CliUsageLimitKind::Rate);
1669        assert_eq!(rate.retry_label, "11:00 AM");
1670    }
1671
1672    #[test]
1673    fn usage_limit_tracker_suppresses_stale_retry_ready_banner_on_new_turn() {
1674        let now_ms = 1_700_000_000_000;
1675        let text = "Usage limit reached. Try again at 3:15 PM.";
1676        let mut tracker = UsageLimitTracker::default();
1677        let initial = detect_cli_usage_limit(text, now_ms).expect("limit");
1678        tracker.begin_turn(text, initial.retry_at_ms + 1);
1679        let (status, usage_limit) =
1680            tracker.classify(text, ScreenStatus::Working, initial.retry_at_ms + 1);
1681        assert_eq!(status, ScreenStatus::Working);
1682        assert_eq!(usage_limit, None);
1683    }
1684
1685    #[test]
1686    fn render_text_screenshot_png_produces_png_bytes() {
1687        let png = render_text_screenshot_png("hello\nworld").expect("png rendered");
1688        assert!(png.starts_with(&[0x89, b'P', b'N', b'G']));
1689        assert!(png.len() > 64);
1690    }
1691
1692    #[test]
1693    fn render_text_screenshot_png_uses_card_viewport_input() {
1694        let screen = card_viewport_text(
1695            &(0..80)
1696                .map(|_| "x".repeat(200))
1697                .collect::<Vec<_>>()
1698                .join("\n"),
1699        );
1700        let png = render_text_screenshot_png(&screen).expect("png rendered");
1701        let image = image::load_from_memory(&png).expect("png should decode");
1702        let expected_width =
1703            ((CARD_VIEWPORT_COLS as f32 * CELL_W).ceil() as u32 + PADDING * 2).clamp(64, 2200);
1704        let expected_height =
1705            ((CARD_VIEWPORT_ROWS as f32 * CELL_H).ceil() as u32 + PADDING * 2).clamp(32, 2200);
1706
1707        assert_eq!(image.width(), expected_width);
1708        assert_eq!(image.height(), expected_height);
1709    }
1710
1711    #[test]
1712    fn screen_analyzer_enablement_requires_complete_config() {
1713        let mut cfg = ScreenAnalyzerConfig::default();
1714        assert!(!screen_analyzer_enabled(&cfg));
1715        cfg.enabled = true;
1716        cfg.base_url = "https://example.com".to_string();
1717        cfg.api_key = "k".to_string();
1718        cfg.model = "m".to_string();
1719        assert!(screen_analyzer_enabled(&cfg));
1720    }
1721
1722    #[test]
1723    fn parse_screen_analyzer_response_accepts_markdown_wrapped_json() {
1724        let content = "```json\n{\"needsInteraction\":false,\"checkAgainWhen\":\"after_5s\"}\n```";
1725        assert_eq!(
1726            parse_screen_analyzer_response(content).check_again_when,
1727            "after_5s"
1728        );
1729        assert_eq!(
1730            parse_screen_analyzer_response("{\"needsInteraction\":false}").check_again_when,
1731            "content_changed"
1732        );
1733    }
1734
1735    #[test]
1736    fn parse_screen_analyzer_response_builds_tui_prompt_keys() {
1737        let content = r#"{
1738          "needsInteraction": true,
1739          "description": "pick one",
1740          "multiSelect": false,
1741          "confirmKey": "Enter",
1742          "options": [
1743            { "label": "1", "text": "alpha", "type": "select", "index": 0 },
1744            { "label": "2", "text": "beta", "type": "confirm", "index": 1 }
1745          ],
1746          "checkAgainWhen": "content_changed"
1747        }"#;
1748        let parsed = parse_screen_analyzer_response(content);
1749        assert!(parsed.needs_interaction);
1750        assert_eq!(parsed.description.as_deref(), Some("pick one"));
1751        assert_eq!(parsed.options.len(), 2);
1752        assert_eq!(parsed.options[0].keys, vec!["Enter"]);
1753        assert_eq!(parsed.options[1].keys, vec!["Down", "Enter"]);
1754    }
1755}
1756
1757pub async fn run_from_init_path(path: &Path) -> Result<()> {
1758    let payload = tokio::fs::read_to_string(path).await?;
1759    let init = serde_json::from_str::<InitConfig>(&payload)?;
1760    run(init).await
1761}