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}