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