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}