pub(crate) mod bg_runtime;
pub(crate) mod commands;
pub(crate) mod file_index;
pub(crate) mod monitor;
pub(crate) mod oauth_poll;
pub(crate) mod usage_monitor;
use commands::execute_slash_command;
pub use commands::{perform_session_rename, validate_session_name, MAX_SESSION_NAME_LEN};
use std::collections::VecDeque;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Result;
use atomcode_core::agent::{
AgentClient, AgentCommand, AgentEvent, AgentPhase, AgentRuntimeFactory,
};
use atomcode_core::config::Config;
use atomcode_core::session::{SessionId, SessionManager};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
use tokio::sync::mpsc;
use base64::Engine;
use atomcode_core::conversation::message::ImagePart;
use crate::commands::{parse_slash_line, CommandRegistry};
use crate::input::history::History;
use crate::input::key_action::{classify, Action};
use crate::input::InputEvent;
use crate::render::{Renderer, UiLine};
use crate::state::{UiPhase, UiState};
use crate::think::ThinkStripper;
fn encode_rgba_to_png(width: u32, height: u32, rgba: &[u8]) -> Option<Vec<u8>> {
let mut buf = Vec::new();
let mut encoder = png::Encoder::new(&mut buf, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().ok()?;
writer.write_image_data(rgba).ok()?;
drop(writer);
Some(buf)
}
fn try_paste_clipboard_image() -> Option<(ImagePart, u64)> {
let mut clipboard = arboard::Clipboard::new().ok()?;
if let Ok(img) = clipboard.get_image() {
let hash = rgba_fingerprint(img.width, img.height, img.bytes.as_ref());
let png_data = encode_rgba_to_png(img.width as u32, img.height as u32, img.bytes.as_ref())?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&png_data);
return Some((
ImagePart {
media_type: "image/png".into(),
data: b64,
},
hash,
));
}
if let Ok(text) = clipboard.get_text() {
let trimmed = text.trim();
let stripped = trimmed.strip_prefix("file://").unwrap_or(trimmed);
if let Ok(decoded) = urlencoding::decode(stripped) {
if let Some(result) = try_attach_image_from_path(&decoded) {
return Some(result);
}
}
}
#[cfg(target_os = "macos")]
if let Some(path) = read_macos_clipboard_file_url() {
if let Some(result) = try_attach_image_from_path(&path) {
return Some(result);
}
}
None
}
fn try_paste_clipboard_text() -> Option<String> {
let mut clipboard = arboard::Clipboard::new().ok()?;
clipboard.get_text().ok().filter(|s| !s.is_empty())
}
#[cfg(target_os = "macos")]
fn read_macos_clipboard_file_url() -> Option<String> {
use objc2_app_kit::{NSPasteboard, NSPasteboardTypeFileURL};
let pb = NSPasteboard::generalPasteboard();
let raw = unsafe { pb.stringForType(NSPasteboardTypeFileURL) }?.to_string();
let stripped = raw.strip_prefix("file://").unwrap_or(&raw);
let decoded = urlencoding::decode(stripped).ok()?;
Some(decoded.into_owned())
}
fn ext_for_mt(mt: &str) -> &'static str {
match mt {
"image/png" => "png",
"image/jpeg" => "jpg",
"image/webp" => "webp",
"image/gif" => "gif",
_ => "bin",
}
}
fn cache_write_image(cache_dir: &std::path::Path, img: &atomcode_core::conversation::message::ImagePart, hash: u64) {
let path = cache_dir.join(format!("{:016x}.{}", hash, ext_for_mt(&img.media_type)));
if path.exists() {
return;
}
if let Err(e) = std::fs::create_dir_all(cache_dir) {
crate::tuix_trace!("IMG", "cache mkdir failed: {}", e);
return;
}
let raw = match base64::engine::general_purpose::STANDARD.decode(&img.data) {
Ok(b) => b,
Err(e) => {
crate::tuix_trace!("IMG", "cache base64 decode failed: {}", e);
return;
}
};
if let Err(e) = std::fs::write(&path, &raw) {
crate::tuix_trace!("IMG", "cache write failed: {}", e);
}
}
pub(crate) fn compute_input_attachments(
state: &crate::state::UiState,
buf_text: &str,
) -> Vec<usize> {
let mut available: std::collections::HashSet<usize> =
state.pending_image_markers.iter().copied().collect();
for refed in &state.pending_recalled_attachments {
available.insert(refed.n);
}
if available.is_empty() {
return Vec::new();
}
let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
let mut out = Vec::new();
let bytes = buf_text.as_bytes();
let needle = b"[Image #";
let mut i = 0;
while i + needle.len() < bytes.len() {
if &bytes[i..i + needle.len()] == needle {
let mut j = i + needle.len();
let mut n: usize = 0;
let mut had_digit = false;
while j < bytes.len() && bytes[j].is_ascii_digit() {
n = n.saturating_mul(10).saturating_add((bytes[j] - b'0') as usize);
j += 1;
had_digit = true;
}
if had_digit && j < bytes.len() && bytes[j] == b']' {
if available.contains(&n) && seen.insert(n) {
out.push(n);
}
i = j + 1;
continue;
}
}
i += 1;
}
out
}
pub(crate) fn hydrate_recalled_attachments(
state: &mut UiState,
line: &mut String,
cache_dir: &std::path::Path,
) -> Vec<String> {
use base64::Engine;
let mut notices = Vec::new();
if state.pending_recalled_attachments.is_empty() {
return notices;
}
for refed in std::mem::take(&mut state.pending_recalled_attachments) {
let cache_path = cache_dir.join(format!("{}.{}", refed.hash, ext_for_mt(&refed.mt)));
match std::fs::read(&cache_path) {
Ok(raw) => {
state.session_image_count += 1;
let new_marker = state.session_image_count;
*line = line.replace(
&format!("[Image #{}]", refed.n),
&format!("[Image #{}]", new_marker),
);
let hash_u64 = u64::from_str_radix(&refed.hash, 16).unwrap_or(0);
state.pending_images.push(atomcode_core::conversation::message::ImagePart {
media_type: refed.mt.clone(),
data: base64::engine::general_purpose::STANDARD.encode(&raw),
});
state.pending_image_hashes.push(hash_u64);
state.pending_image_markers.push(new_marker);
}
Err(_) => {
*line = line.replace(&format!("[Image #{}]", refed.n), "");
notices.push(format!(
"[Image #{}] 缓存已丢失,已从消息中移除",
refed.n
));
}
}
}
notices
}
const MAX_PATH_IMAGE_BYTES: u64 = 20 * 1024 * 1024;
fn try_attach_image_from_path(text: &str) -> Option<(ImagePart, u64)> {
let trimmed = text.trim();
if trimmed.is_empty() || trimmed.contains('\n') {
return None;
}
let unquoted: &str = if trimmed.len() >= 2
&& ((trimmed.starts_with('\'') && trimmed.ends_with('\''))
|| (trimmed.starts_with('"') && trimmed.ends_with('"')))
{
&trimmed[1..trimmed.len() - 1]
} else {
trimmed
};
let unescaped = unquoted.replace("\\ ", " ");
let candidate = unescaped.trim();
let path = std::path::Path::new(candidate);
if !path.is_absolute() {
return None;
}
let media_type = match path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_ascii_lowercase())
.as_deref()
{
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
_ => return None,
};
let meta = std::fs::metadata(path).ok()?;
if !meta.is_file() || meta.len() > MAX_PATH_IMAGE_BYTES {
return None;
}
let bytes = std::fs::read(path).ok()?;
let hash = rgba_fingerprint(0, 0, &bytes);
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
Some((
ImagePart {
media_type: media_type.into(),
data: b64,
},
hash,
))
}
#[cfg(test)]
mod image_path_tests {
use super::*;
use std::io::Write as _;
use tempfile::tempdir;
fn write_tmp_file(dir: &tempfile::TempDir, name: &str, bytes: &[u8]) -> std::path::PathBuf {
let p = dir.path().join(name);
let mut f = std::fs::File::create(&p).expect("create tmp file");
f.write_all(bytes).expect("write tmp file");
p
}
#[test]
fn png_path_attaches_as_image_png() {
let dir = tempdir().unwrap();
let p = write_tmp_file(&dir, "snap.png", b"\x89PNG\r\n\x1a\nstub-bytes");
let res = try_attach_image_from_path(p.to_str().unwrap());
let (img, _) = res.expect("PNG path must be recognised");
assert_eq!(img.media_type, "image/png");
assert!(!img.data.is_empty(), "base64 data must be populated");
}
#[test]
fn jpg_and_jpeg_map_to_image_jpeg() {
let dir = tempdir().unwrap();
for name in ["a.jpg", "b.JPEG", "c.Jpg"] {
let p = write_tmp_file(&dir, name, b"\xff\xd8\xff\xe0\x00\x10JFIF stub");
let (img, _) = try_attach_image_from_path(p.to_str().unwrap())
.unwrap_or_else(|| panic!("expected attachment for {name}"));
assert_eq!(
img.media_type, "image/jpeg",
"{name} must map to image/jpeg"
);
}
}
#[test]
fn quoted_absolute_path_is_recognised() {
let dir = tempdir().unwrap();
let p = write_tmp_file(&dir, "shot with space.png", b"stub");
let path_str = p.to_str().unwrap();
let single_quoted = format!("'{}'", path_str);
let double_quoted = format!("\"{}\"", path_str);
assert!(try_attach_image_from_path(&single_quoted).is_some());
assert!(try_attach_image_from_path(&double_quoted).is_some());
}
#[test]
fn shell_escaped_space_is_unescaped() {
let dir = tempdir().unwrap();
let p = write_tmp_file(&dir, "shot with space.png", b"stub");
let abs = p.to_str().unwrap();
let escaped = abs.replace(' ', "\\ ");
assert!(
try_attach_image_from_path(&escaped).is_some(),
"shell-escaped path must be unescaped before fs lookup"
);
}
#[test]
fn trailing_whitespace_is_trimmed() {
let dir = tempdir().unwrap();
let p = write_tmp_file(&dir, "snap.png", b"stub");
let with_trailing_ws = format!("{} \t ", p.to_str().unwrap());
assert!(try_attach_image_from_path(&with_trailing_ws).is_some());
}
#[test]
fn same_path_yields_same_fingerprint() {
let dir = tempdir().unwrap();
let p = write_tmp_file(&dir, "snap.png", b"stub-bytes");
let path_str = p.to_str().unwrap();
let (_, h1) = try_attach_image_from_path(path_str).unwrap();
let (_, h2) = try_attach_image_from_path(path_str).unwrap();
assert_eq!(h1, h2, "deterministic hash for the same file");
}
#[test]
fn prose_paste_is_not_an_image() {
assert!(try_attach_image_from_path("hello world").is_none());
assert!(try_attach_image_from_path("see /tmp/notes for context").is_none());
assert!(try_attach_image_from_path("").is_none());
assert!(try_attach_image_from_path(" ").is_none());
}
#[test]
fn multi_line_paste_is_not_an_image() {
let two_lines = "/tmp/snap.png\nsecond line";
assert!(try_attach_image_from_path(two_lines).is_none());
}
#[test]
fn relative_path_is_not_attached() {
assert!(try_attach_image_from_path("snap.png").is_none());
assert!(try_attach_image_from_path("./snap.png").is_none());
assert!(try_attach_image_from_path("../snap.png").is_none());
}
#[test]
fn non_image_extension_is_rejected() {
let dir = tempdir().unwrap();
let p = write_tmp_file(&dir, "notes.txt", b"hello");
assert!(try_attach_image_from_path(p.to_str().unwrap()).is_none());
let p2 = write_tmp_file(&dir, "data.json", b"{}");
assert!(try_attach_image_from_path(p2.to_str().unwrap()).is_none());
}
#[test]
fn missing_file_is_rejected() {
assert!(
try_attach_image_from_path("/this/path/definitely/does/not/exist/snap.png").is_none()
);
}
#[test]
fn oversized_file_is_rejected() {
let dir = tempdir().unwrap();
let huge = vec![0u8; (MAX_PATH_IMAGE_BYTES + 1) as usize];
let p = write_tmp_file(&dir, "huge.png", &huge);
assert!(
try_attach_image_from_path(p.to_str().unwrap()).is_none(),
"files over MAX_PATH_IMAGE_BYTES must be rejected before read"
);
}
}
#[cfg(test)]
mod compute_input_attachments_tests {
use super::compute_input_attachments;
use crate::input::history::HistoryImageRef;
use crate::state::UiState;
fn recalled(n: usize) -> HistoryImageRef {
HistoryImageRef {
hash: "0".repeat(16),
mt: "image/png".into(),
n,
}
}
#[test]
fn fresh_paste_marker_emits_preview() {
let mut s = UiState::default();
s.pending_image_markers.push(3);
let attachments = compute_input_attachments(&s, "look [Image #3] here");
assert_eq!(attachments, vec![3]);
}
#[test]
fn cache_recalled_marker_emits_preview() {
let mut s = UiState::default();
s.pending_recalled_attachments.push(recalled(7));
let attachments = compute_input_attachments(&s, "[Image #7] from history");
assert_eq!(attachments, vec![7]);
}
#[test]
fn typed_marker_with_no_pending_emits_no_preview() {
let s = UiState::default();
let attachments = compute_input_attachments(&s, "I typed [Image #99] literally");
assert!(attachments.is_empty(), "literal text must not surface a preview row");
}
#[test]
fn marker_deleted_from_buffer_disappears_from_preview() {
let mut s = UiState::default();
s.pending_image_markers.push(1);
let with_marker = compute_input_attachments(&s, "see [Image #1]");
assert_eq!(with_marker, vec![1]);
let without_marker = compute_input_attachments(&s, "no marker now");
assert!(without_marker.is_empty(), "removing marker text must drop preview row");
}
#[test]
fn duplicate_markers_dedup_to_first_occurrence() {
let mut s = UiState::default();
s.pending_image_markers.push(2);
let attachments = compute_input_attachments(&s, "[Image #2] then [Image #2] again");
assert_eq!(attachments, vec![2], "same marker referenced twice must surface a single preview row");
}
#[test]
fn preserves_first_occurrence_order_across_sources() {
let mut s = UiState::default();
s.pending_image_markers.push(5);
s.pending_recalled_attachments.push(recalled(3));
let attachments = compute_input_attachments(&s, "first [Image #5] then [Image #3]");
assert_eq!(attachments, vec![5, 3], "preview rows follow buffer text order, not source order");
}
}
#[derive(Debug, Clone)]
pub struct McpReloadProgress {
pub total: usize,
pub done: usize,
pub connected: usize,
pub failed: usize,
pub started_at: std::time::Instant,
}
pub struct LoopCtx {
pub config: Config,
pub model_name: String,
pub agent: AgentClient,
pub runtime_factory: AgentRuntimeFactory,
pub bg_manager: bg_runtime::BgRuntimeManager,
pub foreground_runtime_id: bg_runtime::RuntimeId,
pub runtime_event_tx: mpsc::UnboundedSender<bg_runtime::RuntimeEvent>,
pub runtime_event_rx: mpsc::UnboundedReceiver<bg_runtime::RuntimeEvent>,
pub working_dir: PathBuf,
pub previous_dir: Option<PathBuf>,
pub recent_dirs: Vec<PathBuf>,
pub history: History,
pub input_rx: mpsc::UnboundedReceiver<InputEvent>,
pub commands: CommandRegistry,
pub session_manager: SessionManager,
pub current_session: atomcode_core::session::Session,
pub update_hint: std::sync::Arc<std::sync::Mutex<Option<String>>>,
pub monitor_warning: std::sync::Arc<std::sync::Mutex<Option<monitor::CodingPlanWarning>>>,
pub monitor_last_check_at: Option<std::time::Instant>,
pub usage_slot: std::sync::Arc<
std::sync::Mutex<Option<atomcode_core::coding_plan::types::UsageInfo>>,
>,
pub usage_last_check_at: Option<std::time::Instant>,
pub monitor_last_sync_seen: Option<std::time::SystemTime>,
pub wake_rx: mpsc::Receiver<()>,
pub wake_tx: mpsc::Sender<()>,
pub oauth_event_rx: mpsc::UnboundedReceiver<oauth_poll::OauthEvent>,
pub oauth_event_tx: mpsc::UnboundedSender<oauth_poll::OauthEvent>,
pub reader: Option<crate::input::reader::ReaderHandle>,
pub upgrade_tx: mpsc::UnboundedSender<atomcode_core::self_update::UpgradeEvent>,
pub upgrade_rx: mpsc::UnboundedReceiver<atomcode_core::self_update::UpgradeEvent>,
pub plugin_job_tx: mpsc::UnboundedSender<atomcode_core::plugin::PluginJobEvent>,
pub plugin_job_rx: mpsc::UnboundedReceiver<atomcode_core::plugin::PluginJobEvent>,
pub pending_new_issue: Option<NewIssueDraft>,
pub pending_run_codingplan: bool,
pub pending_open_provider_wizard: bool,
pub mcp_registry: Option<std::sync::Arc<atomcode_core::mcp::McpRegistry>>,
pub mcp_connect_rx:
Option<tokio::sync::mpsc::UnboundedReceiver<atomcode_core::mcp::McpConnectEvent>>,
pub mcp_reload: Option<McpReloadProgress>,
pub lsp_connect_rx: Option<tokio::sync::mpsc::UnboundedReceiver<atomcode_core::lsp::LspConnectEvent>>,
pub telemetry: std::sync::Arc<atomcode_telemetry::Telemetry>,
pub worktree_original_dir: Option<PathBuf>,
pub custom_commands: atomcode_core::commands::CustomCommandRegistry,
pub skill_registry: std::sync::Arc<std::sync::RwLock<atomcode_core::skill::SkillRegistry>>,
pub caps: crate::terminal::TerminalCaps,
pub replay_on_start: Option<atomcode_core::session::Session>,
pub file_index: file_index::FileIndex,
pub current_session_id: Option<SessionId>,
pub clipboard_check: std::sync::Arc<std::sync::Mutex<ClipboardCheckState>>,
pub is_plain_renderer: bool,
}
#[derive(Debug, Default)]
pub struct ClipboardCheckState {
pub image_hash: Option<u64>,
pub last_checked: Option<std::time::Instant>,
}
fn rgba_fingerprint(width: usize, height: usize, bytes: &[u8]) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
width.hash(&mut hasher);
height.hash(&mut hasher);
bytes.len().hash(&mut hasher);
let head_end = bytes.len().min(1024);
bytes[..head_end].hash(&mut hasher);
let tail_start = bytes.len().saturating_sub(1024);
bytes[tail_start..].hash(&mut hasher);
hasher.finish()
}
#[derive(Debug, Clone)]
pub struct NewIssueDraft {
pub owner: String,
pub repo: String,
pub title: String,
pub body: String,
}
pub struct Buffer {
pub text: String,
pub cursor: usize,
history_idx: Option<usize>,
stash: String,
pastes: Vec<String>,
}
const PASTE_FOLD_LINES: usize = 5;
const PASTE_FOLD_CHARS: usize = 400;
fn normalize_newlines(s: &str) -> String {
s.replace("\r\n", "\n").replace('\r', "\n")
}
impl Buffer {
fn new() -> Self {
Self {
text: String::new(),
cursor: 0,
history_idx: None,
stash: String::new(),
pastes: Vec::new(),
}
}
pub fn is_in_history(&self) -> bool {
self.history_idx.is_some()
}
pub fn history_idx(&self) -> Option<usize> {
self.history_idx
}
pub fn insert_paste(&mut self, text: String) -> String {
let text = normalize_newlines(&text);
let line_count = text.lines().count().max(1);
let char_count = text.chars().count();
if line_count >= PASTE_FOLD_LINES || char_count >= PASTE_FOLD_CHARS {
let id = self.pastes.len() + 1;
let placeholder = if line_count <= 1 {
format!("[Pasted #{} {} chars]", id, char_count)
} else {
format!("[Pasted #{} +{} lines]", id, line_count)
};
self.pastes.push(text);
self.text.insert_str(self.cursor, &placeholder);
self.cursor += placeholder.len();
placeholder
} else {
let n = text.len();
self.text.insert_str(self.cursor, &text);
self.cursor += n;
text
}
}
fn expand_pastes(&self, line: &str) -> String {
if self.pastes.is_empty() {
return line.to_string();
}
let mut out = String::with_capacity(line.len());
let mut rest = line;
while let Some(start) = rest.find("[Pasted #") {
out.push_str(&rest[..start]);
let tail = &rest[start..];
if let Some(end) = tail.find(']') {
let header = &tail[..=end];
let id_part = header
.strip_prefix("[Pasted #")
.and_then(|s| s.split_whitespace().next());
if let Some(id_str) = id_part {
if let Ok(id) = id_str.parse::<usize>() {
if id >= 1 && id <= self.pastes.len() {
out.push_str(&self.pastes[id - 1]);
rest = &tail[end + 1..];
continue;
}
}
}
out.push_str(header);
rest = &tail[end + 1..];
} else {
out.push_str(tail);
rest = "";
break;
}
}
out.push_str(rest);
out
}
fn clear_pastes(&mut self) {
self.pastes.clear();
}
pub(crate) fn apply(
&mut self,
action: Action,
history: &[crate::input::history::HistoryEntry],
commands: &CommandRegistry,
) -> BufferResult {
match action {
Action::Insert(c) => {
self.text.insert(self.cursor, c);
self.cursor += c.len_utf8();
self.history_idx = None;
BufferResult::Redraw
}
Action::Submit => {
if self.cursor > 0 && self.text.as_bytes()[self.cursor - 1] == b'\\' {
let bs = self.cursor - 1;
self.text.replace_range(bs..self.cursor, "\n");
self.cursor = bs + 1;
self.history_idx = None;
return BufferResult::Redraw;
}
let line = self.text.trim().to_string();
if line.is_empty() {
return BufferResult::Redraw;
}
BufferResult::Commit(line)
}
Action::InsertNewline => {
self.text.insert(self.cursor, '\n');
self.cursor += 1;
BufferResult::Redraw
}
Action::Cancel => {
if self.text.is_empty() {
BufferResult::Exit
} else {
self.text.clear();
self.cursor = 0;
self.history_idx = None;
self.pastes.clear();
BufferResult::Redraw
}
}
Action::ClearLine => {
self.text.clear();
self.cursor = 0;
self.pastes.clear();
BufferResult::Redraw
}
Action::DeleteWordBackward => {
let before = &self.text[..self.cursor];
let trimmed = before.trim_end_matches(' ');
let word_start = trimmed.rfind(' ').map(|i| i + 1).unwrap_or(0);
self.text.drain(word_start..self.cursor);
self.cursor = word_start;
BufferResult::Redraw
}
Action::DeleteToEnd => {
let end = self.text[self.cursor..]
.find('\n')
.map(|i| self.cursor + i)
.unwrap_or(self.text.len());
self.text.drain(self.cursor..end);
BufferResult::Redraw
}
Action::Backspace => {
if self.cursor > 0 {
let p = prev_boundary(&self.text, self.cursor);
self.text.drain(p..self.cursor);
self.cursor = p;
}
BufferResult::Redraw
}
Action::DeleteForward => {
if self.cursor < self.text.len() {
let n = next_boundary(&self.text, self.cursor);
self.text.drain(self.cursor..n);
}
BufferResult::Redraw
}
Action::CursorLeft => {
if self.cursor > 0 {
self.cursor = prev_boundary(&self.text, self.cursor);
}
BufferResult::Redraw
}
Action::CursorRight => {
if self.cursor < self.text.len() {
self.cursor = next_boundary(&self.text, self.cursor);
}
BufferResult::Redraw
}
Action::LineStart => {
let start = self.text[..self.cursor]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0);
self.cursor = start;
BufferResult::Redraw
}
Action::LineEnd => {
let end = self.text[self.cursor..]
.find('\n')
.map(|i| self.cursor + i)
.unwrap_or(self.text.len());
self.cursor = end;
BufferResult::Redraw
}
Action::HistoryPrev => {
if history.is_empty() {
return BufferResult::Redraw;
}
let new_idx = match self.history_idx {
None => {
self.stash = self.text.clone();
Some(history.len() - 1)
}
Some(i) if i > 0 => Some(i - 1),
Some(i) => Some(i),
};
self.history_idx = new_idx;
if let Some(i) = new_idx {
self.text = history[i].text.clone();
self.cursor = 0;
}
BufferResult::Redraw
}
Action::HistoryNext => {
if let Some(i) = self.history_idx {
if i + 1 < history.len() {
self.history_idx = Some(i + 1);
self.text = history[i + 1].text.clone();
self.cursor = 0;
} else {
self.history_idx = None;
self.text = self.stash.clone();
self.cursor = self.text.len();
}
}
BufferResult::Redraw
}
Action::Complete => {
if self.text.starts_with('/') {
let prefix = &self.text[1..];
let matches = commands.matching_prefix(prefix);
if matches.len() == 1 {
self.text = format!("/{} ", matches[0].name);
self.cursor = self.text.len();
}
}
BufferResult::Redraw
}
Action::NoOp => BufferResult::NoOp,
Action::ToggleToolOutput => BufferResult::NoOp,
}
}
pub fn cursor_line_up(&mut self) -> bool {
let cur_line_start = self.text[..self.cursor]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0);
if cur_line_start == 0 {
if self.cursor > 0 {
self.cursor = 0;
return true;
}
return false;
}
let target_col = crate::width::display_width(&self.text[cur_line_start..self.cursor]);
let prev_line_end = cur_line_start - 1;
let prev_line_start = self.text[..prev_line_end]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0);
self.cursor =
prev_line_start + byte_offset_at_col(&self.text[prev_line_start..prev_line_end], target_col);
true
}
pub fn cursor_line_down(&mut self) -> bool {
let Some(rel_end) = self.text[self.cursor..].find('\n') else {
if self.cursor < self.text.len() {
self.cursor = self.text.len();
return true;
}
return false;
};
let cur_line_start = self.text[..self.cursor]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0);
let target_col = crate::width::display_width(&self.text[cur_line_start..self.cursor]);
let next_line_start = self.cursor + rel_end + 1;
let next_line_end = self.text[next_line_start..]
.find('\n')
.map(|i| next_line_start + i)
.unwrap_or(self.text.len());
self.cursor =
next_line_start + byte_offset_at_col(&self.text[next_line_start..next_line_end], target_col);
true
}
}
fn byte_offset_at_col(line: &str, target_col: usize) -> usize {
let mut acc = 0usize;
for (i, ch) in line.char_indices() {
if acc >= target_col {
return i;
}
acc += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
}
line.len()
}
#[cfg(test)]
mod buffer_tests {
use super::*;
#[test]
fn small_paste_inserts_inline() {
let mut b = Buffer::new();
b.insert_paste("hi\n".to_string());
assert_eq!(b.text, "hi\n");
assert!(b.pastes.is_empty(), "small paste should not fold");
}
#[test]
fn large_paste_folds_into_placeholder() {
let mut b = Buffer::new();
let big = "line\n".repeat(10);
b.insert_paste(big.clone());
assert!(b.text.contains("[Pasted #1 +10 lines]"));
assert_eq!(b.pastes, vec![big]);
}
#[test]
fn expand_pastes_restores_original() {
let mut b = Buffer::new();
let big = "line\n".repeat(10);
b.insert_paste(big.clone());
let committed = b.text.clone();
let expanded = b.expand_pastes(&committed);
assert_eq!(expanded, big);
}
#[test]
fn expand_pastes_is_noop_without_placeholders() {
let b = Buffer::new();
assert_eq!(b.expand_pastes("plain text"), "plain text");
}
#[test]
fn paste_with_cr_separators_folds_correctly() {
let mut b = Buffer::new();
let cr_paste: String = (1..=20).map(|i| format!("line{}\r", i)).collect();
b.insert_paste(cr_paste.clone());
assert!(
b.text.contains("+20 lines"),
"expected 20-line placeholder, got: {}",
b.text
);
assert!(!b.pastes[0].contains('\r'));
let expanded = b.expand_pastes(&b.text);
assert_eq!(expanded.lines().count(), 20);
}
#[test]
fn expand_handles_multiple_pastes_interleaved() {
let mut b = Buffer::new();
b.insert_paste("A\n".repeat(6));
b.text.insert_str(b.cursor, " then ");
b.cursor += 6;
b.insert_paste("B\n".repeat(6));
let line = b.text.clone();
let out = b.expand_pastes(&line);
assert!(out.contains("A\n"));
assert!(out.contains(" then "));
assert!(out.contains("B\n"));
assert!(!out.contains("[Pasted"));
}
#[test]
fn clear_before_expand_loses_paste_body() {
let mut b = Buffer::new();
let body = "important data\n".repeat(200);
b.insert_paste(body.clone());
let line = b.text.clone();
b.clear_pastes();
let expanded = b.expand_pastes(&line);
assert!(
expanded.contains("[Pasted #1"),
"early-clear must leave the placeholder unsubstituted: {}",
expanded
);
assert!(
!expanded.contains("important data"),
"early-clear must NOT magically still have the body: {}",
expanded
);
let mut b = Buffer::new();
b.insert_paste(body.clone());
let line = b.text.clone();
let expanded = b.expand_pastes(&line);
b.clear_pastes();
assert!(
expanded.contains("important data"),
"expand-before-clear must surface the body: {}",
&expanded[..expanded.len().min(120)]
);
assert!(b.pastes.is_empty(), "clear after expand must still empty the registry");
}
#[test]
fn submit_with_trailing_backslash_inserts_newline() {
let reg = CommandRegistry::builtin();
let history: Vec<crate::input::history::HistoryEntry> = Vec::new();
let mut b = Buffer::new();
b.text = "hello\\".to_string();
b.cursor = b.text.len();
let r = b.apply(Action::Submit, &history, ®);
assert!(matches!(r, BufferResult::Redraw));
assert_eq!(b.text, "hello\n");
assert_eq!(b.cursor, b.text.len());
}
#[test]
fn submit_with_backslash_mid_buffer_inserts_newline_at_cursor() {
let reg = CommandRegistry::builtin();
let history: Vec<crate::input::history::HistoryEntry> = Vec::new();
let mut b = Buffer::new();
b.text = "abc\\def".to_string();
b.cursor = 4; let r = b.apply(Action::Submit, &history, ®);
assert!(matches!(r, BufferResult::Redraw));
assert_eq!(b.text, "abc\ndef");
assert_eq!(b.cursor, 4);
}
#[test]
fn submit_without_trailing_backslash_commits_normally() {
let reg = CommandRegistry::builtin();
let history: Vec<crate::input::history::HistoryEntry> = Vec::new();
let mut b = Buffer::new();
b.text = "ship it".to_string();
b.cursor = b.text.len();
let r = b.apply(Action::Submit, &history, ®);
match r {
BufferResult::Commit(s) => assert_eq!(s, "ship it"),
_ => panic!("expected Commit"),
}
}
#[test]
fn submit_with_backslash_not_before_cursor_commits_normally() {
let reg = CommandRegistry::builtin();
let history: Vec<crate::input::history::HistoryEntry> = Vec::new();
let mut b = Buffer::new();
b.text = "abc\\def".to_string();
b.cursor = b.text.len(); let r = b.apply(Action::Submit, &history, ®);
match r {
BufferResult::Commit(s) => assert_eq!(s, "abc\\def"),
_ => panic!("expected Commit"),
}
}
}
#[cfg(test)]
mod menu_tests {
use super::*;
use atomcode_core::commands::CustomCommandRegistry;
#[test]
fn non_slash_input_returns_no_menu() {
let reg = CommandRegistry::builtin();
let custom = CustomCommandRegistry::empty();
assert!(build_menu_items("hello world", 0, ®, &custom, None, None).is_none());
}
#[test]
fn slash_prefix_returns_all_commands() {
let reg = CommandRegistry::builtin();
let custom = CustomCommandRegistry::empty();
let items = build_menu_items("/", 0, ®, &custom, None, None).expect("menu should show for '/'");
assert!(!items.is_empty(), "builtin registry should have commands");
}
#[test]
fn slash_with_filter_narrows_list() {
let reg = CommandRegistry::builtin();
let custom = CustomCommandRegistry::empty();
let all = build_menu_items("/", 0, ®, &custom, None, None).unwrap();
let filtered = build_menu_items("/he", 0, ®, &custom, None, None).unwrap_or_default();
assert!(
filtered.len() < all.len(),
"prefix '/he' should filter builtin commands"
);
for (name, _) in &filtered {
assert!(
name.starts_with("he"),
"prefix filter leaked non-matching '{}'",
name
);
}
}
#[test]
fn whitespace_after_slash_closes_menu() {
let reg = CommandRegistry::builtin();
let custom = CustomCommandRegistry::empty();
assert!(build_menu_items("/cd ", 0, ®, &custom, None, None).is_none());
assert!(build_menu_items("/cd /tmp", 0, ®, &custom, None, None).is_none());
}
#[test]
fn slash_with_no_matches_returns_none() {
let reg = CommandRegistry::builtin();
let custom = CustomCommandRegistry::empty();
assert!(build_menu_items("/zzznomatch", 0, ®, &custom, None, None).is_none());
}
fn skill_fixture(name: &str, desc: &str, user_invocable: bool) -> atomcode_core::skill::Skill {
atomcode_core::skill::Skill {
name: name.to_string(),
description: desc.to_string(),
template: "do thing".to_string(),
disable_model_invocation: false,
user_invocable,
argument_hint: None,
allowed_tools: vec![],
skill_dir: std::path::PathBuf::new(),
source_path: std::path::PathBuf::new(),
}
}
#[test]
fn top_level_hides_individual_skills() {
let reg = CommandRegistry::builtin();
let custom = CustomCommandRegistry::empty();
let mut skills = atomcode_core::skill::SkillRegistry::new();
skills.register(skill_fixture("skills:brainstorming", "Brainstorm", true));
skills.register(skill_fixture("skills:web-access", "Web", true));
let lock = std::sync::RwLock::new(skills);
assert!(
build_menu_items("/bra", 0, ®, &custom, Some(&lock), None).is_none(),
"individual skills must not leak into the top-level menu"
);
let items = build_menu_items("/skills", 0, ®, &custom, Some(&lock), None)
.expect("/skills must include the built-in gateway");
assert!(items.iter().any(|(n, _)| n == "skills"));
for (n, _) in &items {
assert!(
!n.contains(':'),
"namespaced skill leaked into top-level: {}",
n
);
}
}
#[test]
fn skills_sub_mode_lists_skills_under_bare_names() {
let reg = CommandRegistry::builtin();
let custom = CustomCommandRegistry::empty();
let mut skills = atomcode_core::skill::SkillRegistry::new();
skills.register(skill_fixture("skills:brainstorming", "Brainstorm", true));
skills.register(skill_fixture("skills:web-access", "Web", true));
let lock = std::sync::RwLock::new(skills);
let items = build_menu_items("/skills ", 0, ®, &custom, Some(&lock), None)
.expect("/skills (with space) must list skills");
assert!(items.iter().any(|(n, _)| n == "skills:brainstorming"));
assert!(items.iter().any(|(n, _)| n == "skills:web-access"));
for (n, _) in &items {
assert!(n.contains(':'), "sub-mode names must be qualified: {}", n);
}
}
#[test]
fn skills_sub_mode_filters_by_bare_prefix() {
let reg = CommandRegistry::builtin();
let custom = CustomCommandRegistry::empty();
let mut skills = atomcode_core::skill::SkillRegistry::new();
skills.register(skill_fixture("skills:brainstorming", "Brainstorm", true));
skills.register(skill_fixture("skills:web-access", "Web", true));
let lock = std::sync::RwLock::new(skills);
let bra = build_menu_items("/skills bra", 0, ®, &custom, Some(&lock), None)
.expect("filter must produce a result");
assert_eq!(bra.len(), 1);
assert_eq!(bra[0].0, "skills:brainstorming");
let web = build_menu_items("/skills web", 0, ®, &custom, Some(&lock), None)
.expect("filter must produce a result");
assert_eq!(web.len(), 1);
assert_eq!(web[0].0, "skills:web-access");
assert!(build_menu_items("/skills zz", 0, ®, &custom, Some(&lock), None).is_none());
}
#[test]
fn skills_sub_mode_hides_after_skill_name() {
let reg = CommandRegistry::builtin();
let custom = CustomCommandRegistry::empty();
let mut skills = atomcode_core::skill::SkillRegistry::new();
skills.register(skill_fixture("skills:brainstorming", "Brainstorm", true));
let lock = std::sync::RwLock::new(skills);
assert!(build_menu_items("/skills brainstorming why", 0, ®, &custom, Some(&lock), None).is_none());
}
#[test]
fn skills_sub_mode_excludes_hidden_skills() {
let reg = CommandRegistry::builtin();
let custom = CustomCommandRegistry::empty();
let mut skills = atomcode_core::skill::SkillRegistry::new();
skills.register(skill_fixture("skills:visible", "shown", true));
skills.register(skill_fixture("skills:hidden", "hidden", false));
let lock = std::sync::RwLock::new(skills);
let items = build_menu_items("/skills ", 0, ®, &custom, Some(&lock), None)
.expect("at least one visible skill should produce a menu");
assert!(items.iter().any(|(n, _)| n == "skills:visible"));
assert!(
!items.iter().any(|(n, _)| n == "skills:hidden"),
"user_invocable=false skill leaked into sub-menu"
);
}
#[test]
fn no_skill_registry_is_no_op() {
let reg = CommandRegistry::builtin();
let custom = CustomCommandRegistry::empty();
let with_none = build_menu_items("/", 0, ®, &custom, None, None).unwrap();
let empty_skills = std::sync::RwLock::new(atomcode_core::skill::SkillRegistry::new());
let with_empty = build_menu_items("/", 0, ®, &custom, Some(&empty_skills), None).unwrap();
assert_eq!(
with_none.len(),
with_empty.len(),
"empty registry must produce same menu as None"
);
}
#[test]
fn history_prev_parks_cursor_at_zero_and_marks_history_mode() {
let mut buf = Buffer::new();
let reg = CommandRegistry::builtin();
let history = vec![crate::input::history::HistoryEntry { text: "/session foo".into(), images: vec![] }];
let _ = buf.apply(Action::HistoryPrev, &history, ®);
assert_eq!(buf.text, "/session foo");
assert_eq!(buf.cursor, 0, "cursor must park at 0 to suppress menu");
assert!(buf.is_in_history(), "buffer must report history mode");
}
#[test]
fn cursor_line_up_walks_lines_then_signals_history_at_top() {
let mut buf = Buffer::new();
buf.text = "1\n2\n3".into();
buf.cursor = buf.text.len();
assert!(buf.cursor_line_up(), "first Up moves up from line 3");
assert_eq!(&buf.text[..buf.cursor], "1\n2");
assert!(buf.cursor_line_up(), "second Up moves up from line 2");
assert_eq!(&buf.text[..buf.cursor], "1");
assert!(
buf.cursor_line_up(),
"on the first line Up snaps to byte 0 before yielding"
);
assert_eq!(buf.cursor, 0);
assert!(
!buf.cursor_line_up(),
"already at byte 0 → caller falls through to HistoryPrev"
);
}
#[test]
fn cursor_line_down_walks_lines_then_signals_history_at_bottom() {
let mut buf = Buffer::new();
buf.text = "1\n2\n3".into();
buf.cursor = 0;
assert!(buf.cursor_line_down(), "Down from line 1 → line 2");
assert_eq!(&buf.text[..buf.cursor], "1\n");
assert!(buf.cursor_line_down(), "Down from line 2 → line 3");
assert_eq!(&buf.text[..buf.cursor], "1\n2\n");
assert!(
buf.cursor_line_down(),
"on the last line Down snaps to end-of-buffer before yielding"
);
assert_eq!(buf.cursor, buf.text.len());
assert!(
!buf.cursor_line_down(),
"already at end → caller falls through to HistoryNext"
);
}
#[test]
fn cursor_line_up_snaps_to_start_on_single_line() {
let mut buf = Buffer::new();
buf.text = "hello world".into();
buf.cursor = 7;
assert!(buf.cursor_line_up(), "Up snaps to byte 0 on single line");
assert_eq!(buf.cursor, 0);
assert!(!buf.cursor_line_up(), "second Up yields to history");
}
#[test]
fn cursor_line_down_snaps_to_end_on_single_line() {
let mut buf = Buffer::new();
buf.text = "hello world".into();
buf.cursor = 4;
assert!(buf.cursor_line_down(), "Down snaps to end on single line");
assert_eq!(buf.cursor, buf.text.len());
assert!(!buf.cursor_line_down(), "second Down yields to history");
}
#[test]
fn cursor_line_up_clamps_to_shorter_line() {
let mut buf = Buffer::new();
buf.text = "ab\nhello".into();
buf.cursor = buf.text.len();
assert!(buf.cursor_line_up());
assert_eq!(buf.cursor, 2, "cursor clamps to end of shorter prev line");
}
#[test]
fn cursor_line_up_handles_cjk_width() {
let mut buf = Buffer::new();
buf.text = "你好world\nabcd".into();
buf.cursor = buf.text.len();
assert!(buf.cursor_line_up());
assert_eq!(buf.cursor, 6);
}
#[test]
fn history_next_back_to_stash_restores_cursor_to_end() {
let mut buf = Buffer::new();
let reg = CommandRegistry::builtin();
let history = vec![crate::input::history::HistoryEntry { text: "/session foo".into(), images: vec![] }];
let _ = buf.apply(Action::Insert('h'), &history, ®);
let _ = buf.apply(Action::Insert('i'), &history, ®);
let _ = buf.apply(Action::HistoryPrev, &history, ®);
assert!(buf.is_in_history());
let _ = buf.apply(Action::HistoryNext, &history, ®);
assert_eq!(buf.text, "hi");
assert_eq!(buf.cursor, 2);
assert!(!buf.is_in_history());
}
#[test]
fn typing_clears_history_mode() {
let mut buf = Buffer::new();
let reg = CommandRegistry::builtin();
let history = vec![crate::input::history::HistoryEntry { text: "/session foo".into(), images: vec![] }];
let _ = buf.apply(Action::HistoryPrev, &history, ®);
assert!(buf.is_in_history());
let _ = buf.apply(Action::Insert('x'), &history, ®);
assert!(!buf.is_in_history());
}
#[test]
fn sync_recalled_attachments_mirrors_buffer_history_idx() {
use crate::input::history::{HistoryEntry, HistoryImageRef};
let history: Vec<HistoryEntry> = vec![
HistoryEntry { text: "no img".into(), images: vec![] },
HistoryEntry {
text: "with img".into(),
images: vec![HistoryImageRef {
hash: "deadbeef12345678".into(),
mt: "image/png".into(),
n: 1,
}],
},
];
let reg = CommandRegistry::builtin();
let mut buf = Buffer::new();
let mut state = UiState::new();
let _ = buf.apply(Action::HistoryPrev, &history, ®);
super::sync_recalled_attachments(&mut state, &buf, &history);
assert_eq!(state.pending_recalled_attachments.len(), 1);
let _ = buf.apply(Action::HistoryPrev, &history, ®);
super::sync_recalled_attachments(&mut state, &buf, &history);
assert!(state.pending_recalled_attachments.is_empty());
let _ = buf.apply(Action::Insert('a'), &history, ®);
super::sync_recalled_attachments(&mut state, &buf, &history);
assert!(state.pending_recalled_attachments.is_empty());
}
#[test]
fn sync_recalled_attachments_retains_on_edit_when_marker_present() {
use crate::input::history::{HistoryEntry, HistoryImageRef};
let history: Vec<HistoryEntry> = vec![HistoryEntry {
text: "[Image #1]hello".into(),
images: vec![HistoryImageRef {
hash: "deadbeef12345678".into(),
mt: "image/png".into(),
n: 1,
}],
}];
let reg = CommandRegistry::builtin();
let mut buf = Buffer::new();
let mut state = UiState::new();
let _ = buf.apply(Action::HistoryPrev, &history, ®);
super::sync_recalled_attachments(&mut state, &buf, &history);
assert_eq!(state.pending_recalled_attachments.len(), 1);
let _ = buf.apply(Action::Insert('!'), &history, ®);
super::sync_recalled_attachments(&mut state, &buf, &history);
assert_eq!(
state.pending_recalled_attachments.len(),
1,
"edit that leaves marker intact must preserve recalled ref"
);
}
#[test]
fn sync_recalled_attachments_drops_when_marker_removed() {
use crate::input::history::{HistoryEntry, HistoryImageRef};
let history: Vec<HistoryEntry> = vec![HistoryEntry {
text: "[Image #1]hi".into(),
images: vec![HistoryImageRef {
hash: "deadbeef12345678".into(),
mt: "image/png".into(),
n: 1,
}],
}];
let reg = CommandRegistry::builtin();
let mut buf = Buffer::new();
let mut state = UiState::new();
let _ = buf.apply(Action::HistoryPrev, &history, ®);
super::sync_recalled_attachments(&mut state, &buf, &history);
assert_eq!(state.pending_recalled_attachments.len(), 1);
let mut buf2 = Buffer::new();
let _ = buf2.apply(Action::Insert('h'), &history, ®);
let _ = buf2.apply(Action::Insert('i'), &history, ®);
super::sync_recalled_attachments(&mut state, &buf2, &history);
assert!(
state.pending_recalled_attachments.is_empty(),
"removed marker must drop the matching recalled ref"
);
}
#[test]
fn cache_write_image_writes_and_is_idempotent() {
use base64::Engine;
let dir = tempfile::tempdir().unwrap();
let img = atomcode_core::conversation::message::ImagePart {
media_type: "image/png".into(),
data: base64::engine::general_purpose::STANDARD.encode(b"hello"),
};
super::cache_write_image(dir.path(), &img, 0xdead_beef_1234_5678);
let p = dir.path().join("deadbeef12345678.png");
assert!(p.exists());
assert_eq!(std::fs::read(&p).unwrap(), b"hello");
let mtime1 = std::fs::metadata(&p).unwrap().modified().unwrap();
super::cache_write_image(dir.path(), &img, 0xdead_beef_1234_5678);
let mtime2 = std::fs::metadata(&p).unwrap().modified().unwrap();
assert_eq!(mtime1, mtime2, "second call must short-circuit on exists");
}
#[test]
fn hydrate_renumbers_and_rewrites_line() {
use crate::input::history::HistoryImageRef;
let dir = tempfile::tempdir().unwrap();
let cache_dir = dir.path().to_path_buf();
std::fs::write(cache_dir.join("deadbeef12345678.png"), b"\x89PNG").unwrap();
let mut state = UiState::new();
state.session_image_count = 5; state.pending_recalled_attachments.push(HistoryImageRef {
hash: "deadbeef12345678".into(),
mt: "image/png".into(),
n: 2, });
let mut line = "look at [Image #2] please".to_string();
let notice = super::hydrate_recalled_attachments(&mut state, &mut line, &cache_dir);
assert_eq!(notice.len(), 0, "no cache miss expected");
assert_eq!(line, "look at [Image #6] please", "marker renumbered to #6");
assert_eq!(state.pending_images.len(), 1);
assert_eq!(state.pending_image_markers, vec![6]);
assert_eq!(state.session_image_count, 6);
assert!(state.pending_recalled_attachments.is_empty());
}
#[test]
fn hydrate_strips_marker_on_cache_miss() {
use crate::input::history::HistoryImageRef;
let dir = tempfile::tempdir().unwrap();
let cache_dir = dir.path().to_path_buf();
let mut state = UiState::new();
state.pending_recalled_attachments.push(HistoryImageRef {
hash: "0000000000000000".into(),
mt: "image/png".into(),
n: 3,
});
let mut line = "before [Image #3] after".to_string();
let notice = super::hydrate_recalled_attachments(&mut state, &mut line, &cache_dir);
assert_eq!(line, "before after", "marker stripped on cache miss");
assert_eq!(state.pending_images.len(), 0);
assert!(state.pending_recalled_attachments.is_empty());
assert_eq!(notice.len(), 1, "expected one cache-miss notice");
assert!(notice[0].contains("[Image #3]"));
assert!(notice[0].contains("缓存"));
}
#[test]
fn paste_submit_recall_submit_rehydrates_image() {
use crate::input::history::{History, HistoryEntry, HistoryImageRef};
use base64::Engine;
let tmp = tempfile::tempdir().unwrap();
let cache_dir = tmp.path().join("image-cache");
std::fs::create_dir(&cache_dir).unwrap();
let hist_path = tmp.path().join("hist");
let mut history = History::load_with_cache(&hist_path, cache_dir.clone());
let raw_bytes = b"\x89PNG\r\n\x1a\nfake".to_vec();
let img = atomcode_core::conversation::message::ImagePart {
media_type: "image/png".into(),
data: base64::engine::general_purpose::STANDARD.encode(&raw_bytes),
};
let hash: u64 = 0xdead_beef_1234_5678;
super::cache_write_image(&cache_dir, &img, hash);
history.push(HistoryEntry {
text: "describe [Image #1]".into(),
images: vec![HistoryImageRef {
hash: format!("{:016x}", hash),
mt: img.media_type.clone(),
n: 1,
}],
});
history.save().unwrap();
assert!(cache_dir.join(format!("{:016x}.png", hash)).exists());
let history2 = History::load_with_cache(&hist_path, cache_dir.clone());
assert_eq!(history2.entries().len(), 1);
assert_eq!(history2.entries()[0].images.len(), 1);
let mut state = UiState::new();
state.pending_recalled_attachments = history2.entries()[0].images.clone();
let mut line = history2.entries()[0].text.clone();
let notices = super::hydrate_recalled_attachments(&mut state, &mut line, &cache_dir);
assert!(notices.is_empty(), "cache hit, no notice expected");
assert_eq!(state.pending_images.len(), 1, "image rehydrated");
let rehydrated = base64::engine::general_purpose::STANDARD
.decode(&state.pending_images[0].data)
.unwrap();
assert_eq!(rehydrated, raw_bytes, "bytes round-trip exact");
assert_eq!(line, "describe [Image #1]");
assert_eq!(state.pending_image_markers, vec![1]);
}
#[test]
fn hydrate_runs_for_streaming_queued_submit_too() {
use crate::input::history::HistoryImageRef;
let dir = tempfile::tempdir().unwrap();
let cache_dir = dir.path().to_path_buf();
std::fs::write(cache_dir.join("deadbeef12345678.png"), b"\x89PNG").unwrap();
let mut state = UiState::new();
state.pending_recalled_attachments.push(HistoryImageRef {
hash: "deadbeef12345678".into(),
mt: "image/png".into(),
n: 4,
});
let mut line = "describe [Image #4]".to_string();
let _ = super::hydrate_recalled_attachments(&mut state, &mut line, &cache_dir);
assert_eq!(state.pending_images.len(), 1);
assert_eq!(line, "describe [Image #1]"); assert_eq!(state.pending_image_markers, vec![1]);
assert!(line.contains("[Image #1]"), "marker survives in line for the survival filter");
}
}
#[cfg(test)]
mod tool_format_tests {
use super::*;
#[test]
fn fmt_elapsed_under_one_minute_uses_seconds_only() {
assert_eq!(fmt_elapsed(0), "0s");
assert_eq!(fmt_elapsed(999), "0s");
assert_eq!(fmt_elapsed(1_000), "1s");
assert_eq!(fmt_elapsed(45_500), "45s");
}
#[test]
fn fmt_elapsed_above_one_minute_splits_minutes_and_seconds() {
assert_eq!(fmt_elapsed(60_000), "1m0s");
assert_eq!(fmt_elapsed(141_000), "2m21s");
assert_eq!(fmt_elapsed(342_000), "5m42s");
}
#[test]
fn display_tool_name_snake_to_pascal() {
assert_eq!(display_tool_name("read_file"), "ReadFile");
assert_eq!(display_tool_name("search_replace"), "SearchReplace");
assert_eq!(display_tool_name("bash"), "Bash");
}
#[test]
fn display_tool_name_handles_edge_cases() {
assert_eq!(display_tool_name(""), "");
assert_eq!(display_tool_name("x"), "X");
assert_eq!(display_tool_name("x_"), "X");
assert_eq!(display_tool_name("_x"), "X");
}
#[test]
fn display_tool_name_short_strips_redundant_noun() {
assert_eq!(display_tool_name_short("read_file"), "Read");
assert_eq!(display_tool_name_short("write_file"), "Write");
assert_eq!(display_tool_name_short("edit_file"), "Edit");
assert_eq!(display_tool_name_short("create_file"), "Create");
assert_eq!(display_tool_name_short("list_directory"), "List");
assert_eq!(display_tool_name_short("parallel_edit_files"), "ParallelEdit");
assert_eq!(display_tool_name_short("bash"), "Bash");
assert_eq!(display_tool_name_short("grep"), "Grep");
assert_eq!(display_tool_name_short("search_replace"), "SearchReplace");
assert_eq!(display_tool_name_short("web_fetch"), "WebFetch");
assert_eq!(display_tool_name_short("blast_radius"), "BlastRadius");
}
#[test]
fn format_tool_detail_read_file_basename() {
let args = r#"{"file_path":"/abs/path/to/foo.rs"}"#;
assert_eq!(format_tool_detail("read_file", args), "foo.rs");
}
#[test]
fn format_tool_detail_read_symbol_combines_symbol_and_file() {
let args = r#"{"symbol":"parse","file_path":"src/lexer.rs"}"#;
assert_eq!(format_tool_detail("read_symbol", args), "parse in lexer.rs");
}
#[test]
fn format_tool_detail_bash_truncates_at_500() {
let args = format!(r#"{{"command":"{}"}}"#, "a".repeat(600));
let out = format_tool_detail("bash", &args);
assert_eq!(out.len(), 502, "byte length: 499 'a' + 3-byte '…'");
assert!(out.ends_with('…'), "should end with Unicode ellipsis");
assert_eq!(&out[..499], "a".repeat(499));
}
#[test]
fn format_tool_detail_bash_preserves_short_command() {
let args = format!(r#"{{"command":"{}"}}"#, "a".repeat(500));
let out = format_tool_detail("bash", &args);
assert_eq!(out, "a".repeat(500));
}
#[test]
fn format_tool_detail_unknown_tool_falls_back_to_common_keys() {
let args = r#"{"file_path":"/tmp/a.txt","extra":"x"}"#;
let out = format_tool_detail("my_custom_tool", args);
assert!(!out.is_empty(), "fallback should find file_path");
}
#[test]
fn format_tool_detail_invalid_json_returns_empty() {
let out = format_tool_detail("read_file", "not json");
assert_eq!(out, "");
}
#[test]
fn format_tool_detail_search_replace_shows_arrow() {
let args = r#"{"search":"bg-blue-600","replace":"bg-violet-600","glob":"*.vue"}"#;
let out = format_tool_detail("search_replace", args);
assert!(
out.contains("bg-blue-600"),
"should contain search term: got {:?}",
out
);
assert!(
out.contains("bg-violet-600"),
"should contain replace term: got {:?}",
out
);
assert!(
out.contains("→"),
"should contain arrow separator: got {:?}",
out
);
assert!(
out.contains("glob: *.vue"),
"should contain glob info: got {:?}",
out
);
}
#[test]
fn format_tool_detail_search_replace_without_glob() {
let args = r#"{"search":"oldFunc","replace":"newFunc"}"#;
let out = format_tool_detail("search_replace", args);
assert_eq!(out, "oldFunc → newFunc");
}
#[test]
fn format_tool_detail_search_replace_with_path() {
let args = r#"{"search":"foo","replace":"bar","path":"/some/dir"}"#;
let out = format_tool_detail("search_replace", args);
assert!(
out.contains("path: dir"),
"should contain path basename: got {:?}",
out
);
}
#[test]
fn format_tool_detail_search_replace_dot_path_omitted() {
let args = r#"{"search":"foo","replace":"bar","path":"."}"#;
let out = format_tool_detail("search_replace", args);
assert!(
!out.contains("path:"),
"default '.' path should be omitted: got {:?}",
out
);
}
#[test]
fn disambiguate_no_duplicates_returns_as_is() {
let names = vec!["read_file", "read_file"];
let args = vec![
r#"{"file_path":"/a/foo.rs"}"#,
r#"{"file_path":"/b/bar.rs"}"#,
];
let details = vec!["foo.rs".to_string(), "bar.rs".to_string()];
let result = disambiguate_batch_details(&names, &args, &details);
assert_eq!(result, vec!["foo.rs", "bar.rs"]);
}
#[test]
fn disambiguate_same_basename_adds_parent_dir() {
let names = vec!["read_file", "read_file", "read_file"];
let args = vec![
r#"{"file_path":"/home/.atomcode/skills/atomcode-automation-recommender/SKILL.md"}"#,
r#"{"file_path":"/home/.atomcode/skills/tosshub-skill/SKILL.md"}"#,
r#"{"file_path":"/home/.atomcode/skills/zouwu-skill/SKILL.md"}"#,
];
let details = vec![
"SKILL.md".to_string(),
"SKILL.md".to_string(),
"SKILL.md".to_string(),
];
let result = disambiguate_batch_details(&names, &args, &details);
assert_eq!(
result,
vec![
"atomcode-automation-recommender/SKILL.md",
"tosshub-skill/SKILL.md",
"zouwu-skill/SKILL.md",
]
);
}
#[test]
fn disambiguate_partial_duplicates_only_touches_dups() {
let names = vec!["read_file", "read_file", "read_file"];
let args = vec![
r#"{"file_path":"/a/mod.rs"}"#,
r#"{"file_path":"/b/mod.rs"}"#,
r#"{"file_path":"/c/unique.rs"}"#,
];
let details = vec![
"mod.rs".to_string(),
"mod.rs".to_string(),
"unique.rs".to_string(),
];
let result = disambiguate_batch_details(&names, &args, &details);
assert_eq!(result[2], "unique.rs");
assert_eq!(result[0], "a/mod.rs");
assert_eq!(result[1], "b/mod.rs");
}
#[test]
fn disambiguate_no_path_uses_hash_suffix() {
let names = vec!["bash", "bash"];
let args = vec![r#"{"command":"echo hi"}"#, r#"{"command":"echo hi"}"#];
let details = vec!["echo hi".to_string(), "echo hi".to_string()];
let result = disambiguate_batch_details(&names, &args, &details);
assert_eq!(result[0], "echo hi");
assert_eq!(result[1], "echo hi #2");
}
#[test]
fn tail_path_basic() {
assert_eq!(tail_path("a/b/c/SKILL.md", 0), "SKILL.md");
assert_eq!(tail_path("a/b/c/SKILL.md", 1), "c/SKILL.md");
assert_eq!(tail_path("a/b/c/SKILL.md", 2), "b/c/SKILL.md");
assert_eq!(tail_path("a/b/c/SKILL.md", 3), "a/b/c/SKILL.md");
assert_eq!(tail_path("a/b/c/SKILL.md", 10), "a/b/c/SKILL.md");
}
#[test]
fn tail_path_no_parent() {
assert_eq!(tail_path("foo.rs", 0), "foo.rs");
assert_eq!(tail_path("foo.rs", 1), "foo.rs");
}
#[test]
fn disambiguate_long_path_is_truncated() {
let long_seg = "a".repeat(30); let path1 = format!("/x/{}/{}/mod.rs", long_seg, long_seg);
let path2 = format!("/y/{}/{}/mod.rs", long_seg, long_seg);
let names = vec!["read_file", "read_file"];
let args: Vec<String> = vec![
format!(r#"{{"file_path":"{}"}}"#, path1),
format!(r#"{{"file_path":"{}"}}"#, path2),
];
let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let details = vec!["mod.rs".to_string(), "mod.rs".to_string()];
let result = disambiguate_batch_details(&names, &args_refs, &details);
for entry in &result {
assert!(
crate::width::display_width(entry) <= 100,
"entry too wide ({} cols): {}",
crate::width::display_width(entry),
entry,
);
}
assert_ne!(result[0], result[1]);
}
#[test]
fn summarise_single_line_returned_as_is() {
assert_eq!(summarise("ok", true), "ok");
}
#[test]
fn summarise_multi_line_adds_line_count() {
let out = summarise("first line\nsecond line\nthird line", true);
assert!(out.starts_with("first line"));
assert!(out.contains("(3 lines)"));
}
#[test]
fn summarise_empty_string_has_fallback() {
let out = summarise("", true);
assert!(out.contains("(no output)"), "got: {}", out);
}
#[test]
fn summarise_failure_keeps_long_path_intact() {
let err = "Error: old_string not found in \
/mnt/d/docs/work/cangjie/projects/fountain/f_store.";
let out = summarise(err, false);
assert!(
out.contains("/mnt/d/docs/work/cangjie/projects/fountain/f_store"),
"the full path must survive the summary. got: {}",
out
);
assert!(
!out.contains("f_stor "),
"must not produce mid-token truncation like `f_stor ` (note the \
trailing space — that's where (N lines) would attach). got: {}",
out
);
}
#[test]
fn summarise_success_truncates_with_ellipsis_at_80() {
let long: String = "x".repeat(200);
let out = summarise(&long, true);
assert!(
out.ends_with('…'),
"ellipsis is the visible-truncation marker. got: {}",
out
);
assert!(out.chars().count() <= 80);
}
#[test]
fn summarise_failure_multi_line_still_appends_count() {
let err = "Error: foo\nbar\nbaz";
let out = summarise(err, false);
assert!(out.starts_with("Error: foo"));
assert!(out.contains("(3 lines)"));
}
}
pub(crate) enum BufferResult {
NoOp,
Redraw,
Commit(String),
Exit,
}
fn prev_boundary(s: &str, mut p: usize) -> usize {
p -= 1;
while !s.is_char_boundary(p) {
p -= 1;
}
p
}
fn next_boundary(s: &str, mut p: usize) -> usize {
p += 1;
while p < s.len() && !s.is_char_boundary(p) {
p += 1;
}
p
}
pub struct App {
pub state: UiState,
pub buf: Buffer,
pub menu: MenuState,
pub active_modal: Option<Box<dyn crate::modals::Modal>>,
pub message_queue: VecDeque<crate::state::QueuedMessage>,
pub think: ThinkStripper,
pub pending_tools: std::collections::HashMap<String, (String, String, bool)>,
pub exit_pending: Option<std::time::Instant>,
pub fixissue_pending: Option<atomcode_core::atomgit::IssueRef>,
pub fixissue_buffer: String,
pub reasoning_buffer: String,
pub setup_hint_shown: bool,
#[cfg(windows)]
pub last_ctrl_c_copy: Option<std::time::Instant>,
}
const CTRL_C_EXIT_WINDOW: Duration = Duration::from_secs(2);
impl App {
fn new(caps: &crate::terminal::TerminalCaps) -> Self {
Self {
state: UiState::with_unicode(caps.unicode_symbols),
buf: Buffer::new(),
menu: MenuState::new(),
active_modal: None,
message_queue: VecDeque::new(),
think: ThinkStripper::new(),
pending_tools: std::collections::HashMap::new(),
exit_pending: None,
fixissue_pending: None,
fixissue_buffer: String::new(),
reasoning_buffer: String::new(),
setup_hint_shown: false,
#[cfg(windows)]
last_ctrl_c_copy: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExitReason {
Normal,
UpgradeRestart { exe: std::path::PathBuf },
}
pub async fn run_loop(mut ctx: LoopCtx, renderer: &mut dyn Renderer) -> Result<ExitReason> {
let mut app = App::new(&ctx.caps);
crate::tuix_trace!(
"SES",
"run_loop start model={} cwd={}",
ctx.model_name,
ctx.working_dir.display()
);
let dir_display = crate::platform::collapse_home(&ctx.working_dir.to_string_lossy());
renderer.render(UiLine::Welcome {
model: ctx.model_name.clone(),
working_dir: dir_display.clone(),
});
if let Ok(prev) = std::env::var("ATOMCODE_UPGRADED_FROM") {
std::env::remove_var("ATOMCODE_UPGRADED_FROM");
let current = format!("v{}", env!("CARGO_PKG_VERSION"));
renderer.render(UiLine::CommandOutput(
crate::i18n::t(crate::i18n::Msg::UpgradeSuccess { from: &prev, to: ¤t }).into_owned(),
));
}
if let Ok(report) = std::env::var("ATOMCODE_CODINGPLAN_REPORT") {
std::env::remove_var("ATOMCODE_CODINGPLAN_REPORT");
if !report.is_empty() {
renderer.render(UiLine::CommandOutput(report));
}
}
let kbd_hint_set = std::env::var("ATOMCODE_KBD_NOT_ENHANCED").is_ok();
let jediterm_set = std::env::var("ATOMCODE_JEDITERM_FALLBACK").is_ok();
if kbd_hint_set {
std::env::remove_var("ATOMCODE_KBD_NOT_ENHANCED");
}
if kbd_hint_set && !jediterm_set {
renderer.render(UiLine::CommandOutput(
crate::i18n::t(crate::i18n::Msg::HintMultiLineInput).into_owned(),
));
}
if std::env::var("ATOMCODE_JEDITERM_FALLBACK").is_ok() {
std::env::remove_var("ATOMCODE_JEDITERM_FALLBACK");
renderer.render(UiLine::CommandOutput(
" ⓘ JetBrains IDE terminal detected — running in alt-screen mode.\n \
Newlines: end the line with `\\` then press Enter (Shift / Alt /\n \
Ctrl + Enter may also work depending on your IDE version).\n \
Use mouse wheel, PageUp/PageDown, or Shift+Up/Down to scroll history.\n \
Native terminal scrollback is unavailable while atomcode runs;\n \
on exit your host terminal restores its pre-atomcode state.\n \
Set ATOMCODE_PLAIN=1 for a bare CI-style baseline, or\n \
ATOMCODE_RETAIN=1 to bypass this fallback (may misalign).\n\n"
.into(),
));
}
if let Some(session) = ctx.replay_on_start.take() {
if !session.messages.is_empty() {
crate::modals::session_picker::replay_session(renderer, &session, false);
ctx.agent
.cmd_tx
.send(AgentCommand::SetMessages(session.messages.clone()))
.ok();
if let Ok(uuid) = uuid::Uuid::parse_str(session.id.as_str()) {
ctx.telemetry.set_session_id(uuid);
}
ctx.current_session = session;
app.state.on_turn_complete();
}
}
if should_auto_show_onboarding(&ctx) {
use crate::modals::Modal;
renderer.clear_screen();
let mut wizard = crate::modals::OnboardingWizard::new_qr_fast_path();
if let Some(session) = wizard.take_pending_session() {
oauth_poll::spawn_oauth_poll(
session,
Some(std::sync::Arc::clone(&ctx.telemetry)),
ctx.oauth_event_tx.clone(),
ctx.wake_tx.clone(),
);
}
wizard.draw(&app.buf, &app.state, &ctx, renderer);
app.active_modal = Some(Box::new(wizard));
} else {
if !app.setup_hint_shown && should_auto_show_setup(&ctx) {
renderer.render(UiLine::CommandOutput(
crate::i18n::t(crate::i18n::Msg::CmdSetupTip).into_owned(),
));
app.setup_hint_shown = true;
}
renderer.render(UiLine::InputPrompt {
buf: String::new(),
cursor_byte: 0,
menu: None,
status: build_status(&app.state, &ctx),
attachments: Vec::new(),
});
renderer.flush();
}
if monitor::is_codingplan_provider(&ctx.config.default_provider) {
let cooled = ctx
.monitor_last_check_at
.map(|t| t.elapsed() >= monitor::CHECK_COOLDOWN)
.unwrap_or(true);
if cooled {
ctx.monitor_last_check_at = Some(std::time::Instant::now());
monitor::spawn_check(
ctx.config.clone(),
ctx.model_name.clone(),
ctx.monitor_warning.clone(),
ctx.wake_tx.clone(),
);
}
ctx.usage_last_check_at = Some(std::time::Instant::now());
usage_monitor::spawn_check(ctx.usage_slot.clone(), ctx.wake_tx.clone());
}
let (spin_tx, mut spin_rx) = tokio::sync::mpsc::channel::<()>(1);
let spin_task = {
let spin_tx = spin_tx.clone();
tokio::spawn(async move {
use tokio::sync::mpsc::error::TrySendError;
let mut interval = tokio::time::interval(Duration::from_millis(100));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
interval.tick().await; loop {
interval.tick().await;
match spin_tx.try_send(()) {
Ok(_) | Err(TrySendError::Full(_)) => {}
Err(TrySendError::Closed(_)) => break,
}
}
})
};
drop(spin_tx);
let mut deferred_render_tick = tokio::time::interval(Duration::from_millis(5));
deferred_render_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
deferred_render_tick.tick().await;
let mut last_spinner_draw = std::time::Instant::now();
let mut upgrade_last_pct: i32 = -1;
let mut upgrade_done: Option<std::path::PathBuf> = None;
#[cfg(unix)]
let mut sigtstp =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::from_raw(libc::SIGTSTP))?;
#[cfg(unix)]
let mut sigcont =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::from_raw(libc::SIGCONT))?;
#[cfg(windows)]
let mut win_ctrl_c = tokio::signal::windows::ctrl_c()?;
loop {
#[cfg(unix)]
tokio::select! {
biased;
_ = deferred_render_tick.tick() => {
renderer.flush_deferred();
}
Some(()) = spin_rx.recv(), if matches!(app.state.phase, UiPhase::Streaming) => {
draw_spinner_now(&mut app.state, &app.buf, &ctx, renderer, app.message_queue.len(), app.menu.selected);
last_spinner_draw = std::time::Instant::now();
}
maybe = ctx.input_rx.recv() => {
let Some(ev) = maybe else { break };
handle_input(&mut app, &mut ctx, renderer, ev)?;
}
Some(()) = ctx.wake_rx.recv(), if matches!(app.state.phase, UiPhase::Idle) => {
redraw_idle_plain(&app.buf, &app.state, &ctx, renderer);
}
Some(ev) = ctx.oauth_event_rx.recv() => {
use oauth_poll::OauthEvent;
let was_modal_open = app.active_modal.is_some();
if was_modal_open {
app.active_modal = None;
renderer.clear_screen();
}
match ev {
OauthEvent::Authorized => {
crate::modals::onboarding_wizard::paint_welcome(&ctx, renderer);
if let Err(e) = crate::event_loop::commands::run_codingplan_flow(renderer, &mut ctx) {
renderer.render(crate::render::UiLine::Error(
format!("CodingPlan 自动领取失败: {e:#}。可运行 /codingplan 手动重试。"),
));
renderer.flush();
}
let dir_display = crate::platform::collapse_home(
&ctx.working_dir.to_string_lossy(),
);
renderer.refresh_welcome_banner(&ctx.model_name, &dir_display);
if !app.setup_hint_shown && should_auto_show_setup(&ctx) {
renderer.render(crate::render::UiLine::CommandOutput(
crate::i18n::t(crate::i18n::Msg::CmdSetupTip).into_owned(),
));
renderer.flush();
app.setup_hint_shown = true;
}
}
OauthEvent::Failed(reason) => {
renderer.render(crate::render::UiLine::Error(
format!(
"登录失败: {reason}。运行 /codingplan 可重试。",
),
));
renderer.flush();
}
}
}
Some(ev) = async {
if let Some(rx) = ctx.mcp_connect_rx.as_mut() {
rx.recv().await
} else {
None
}
}, if ctx.mcp_connect_rx.is_some() => {
use atomcode_core::mcp::{McpConnectEvent, register_mcp_tools_async};
match &ev {
McpConnectEvent::Connected { name } => {
renderer.render(UiLine::CommandOutput(
crate::i18n::t(crate::i18n::Msg::McpServerConnected { name }).into_owned(),
));
if let Some(registry) = &ctx.mcp_registry {
let registry = registry.clone();
let tools = ctx.agent.tool_registry.clone();
let name = name.clone();
let tx = registry.event_sender();
tokio::spawn(async move {
let list_timeout = registry.list_tools_timeout(&name).await;
let server_tools = match tokio::time::timeout(
list_timeout,
registry.list_tools_for_server(&name),
)
.await
{
Ok(v) => v,
Err(_) => {
if let Some(tx) = tx {
let _ = tx.send(McpConnectEvent::Warning {
name,
message: format!(
"tools/list timed out after {}s during auto-registration",
list_timeout.as_secs()
),
});
}
return;
}
};
if !server_tools.is_empty() {
register_mcp_tools_async(&tools, registry, server_tools).await;
}
});
}
}
McpConnectEvent::Failed { name, error } => {
renderer.render(UiLine::Error(
crate::i18n::t(crate::i18n::Msg::McpServerFailed { name, error }).into_owned(),
));
}
McpConnectEvent::Warning { name, message } => {
if message.starts_with("tools:\n")
|| message.contains("tools/list timed out")
|| message.contains("tools/list failed")
{
renderer.render(UiLine::CommandOutput(format!(
" [mcp:{}] {}\n",
name,
message.trim_end()
)));
} else {
crate::tuix_trace!("MCP", "server='{}' warning: {}", name, message);
}
}
}
if let Some(p) = ctx.mcp_reload.as_mut() {
match &ev {
McpConnectEvent::Connected { .. } => {
p.done = p.done.saturating_add(1);
p.connected = p.connected.saturating_add(1)
}
McpConnectEvent::Failed { .. } => {
p.done = p.done.saturating_add(1);
p.failed = p.failed.saturating_add(1)
}
McpConnectEvent::Warning { .. } => {}
}
if p.done >= p.total {
let elapsed_ms = p.started_at.elapsed().as_millis();
renderer.render(UiLine::CommandOutput(format!(
" MCP reload complete: {} connected, {} failed ({}ms)\n",
p.connected, p.failed, elapsed_ms
)));
ctx.mcp_reload = None;
}
}
renderer.flush();
}
Some(ev) = async {
if let Some(rx) = ctx.lsp_connect_rx.as_mut() {
rx.recv().await
} else {
None
}
}, if ctx.lsp_connect_rx.is_some() => {
use atomcode_core::lsp::LspConnectEvent;
match &ev {
LspConnectEvent::Started { command, ext } => {
renderer.render(UiLine::CommandOutput(
crate::i18n::t(crate::i18n::Msg::LspServerStarted { name: command, ext }).into_owned(),
));
}
LspConnectEvent::Failed { command, ext, error } => {
renderer.render(UiLine::Error(
crate::i18n::t(crate::i18n::Msg::LspServerFailed { name: command, ext, error }).into_owned(),
));
}
LspConnectEvent::Warning { ext, message } => {
crate::tuix_trace!("LSP", "ext='{}' warning: {}", ext, message);
}
}
renderer.flush();
}
Some(ev) = ctx.upgrade_rx.recv() => {
handle_upgrade_event(ev, &mut upgrade_last_pct, &mut upgrade_done, &mut ctx, renderer);
if upgrade_done.is_some() { break; }
if matches!(app.state.phase, UiPhase::Idle) {
redraw_idle_plain(&app.buf, &app.state, &ctx, renderer);
}
}
Some(ev) = ctx.plugin_job_rx.recv() => {
handle_plugin_job_event(ev, &mut ctx, &app.state, renderer);
if matches!(app.state.phase, UiPhase::Idle) {
redraw_idle_plain(&app.buf, &app.state, &ctx, renderer);
}
}
maybe = ctx.runtime_event_rx.recv() => {
let Some(runtime_event) = maybe else { break };
if runtime_event.runtime_id == ctx.foreground_runtime_id {
let pre_phase = app.state.phase;
handle_agent_event(runtime_event.event, &mut app.state, &mut app.think, renderer, &mut app.pending_tools, &mut ctx, &mut app.fixissue_pending, &mut app.fixissue_buffer, &mut app.reasoning_buffer, &app.buf);
if pre_phase != app.state.phase {
crate::tuix_trace!("PH", "{:?} -> {:?}", pre_phase, app.state.phase);
}
if matches!(app.state.phase, UiPhase::Streaming)
&& last_spinner_draw.elapsed() >= Duration::from_millis(100)
{
draw_spinner_now(&mut app.state, &app.buf, &ctx, renderer, app.message_queue.len(), app.menu.selected);
last_spinner_draw = std::time::Instant::now();
}
if matches!(app.state.phase, UiPhase::Idle) {
if let Some(queued) = app.message_queue.pop_front() {
crate::tuix_trace!("QUE", "pop_front remaining={}", app.message_queue.len());
renderer.render(UiLine::User(queued.text.clone()));
renderer.flush();
ctx.agent.cmd_tx.send(AgentCommand::SendMessage {
text: queued.text,
images: queued.images,
image_markers: queued.image_markers,
}).ok();
app.state.on_submit();
draw_spinner_now(&mut app.state, &app.buf, &ctx, renderer, app.message_queue.len(), app.menu.selected);
} else {
crate::tuix_trace!("PH", "turn_end -> Idle, queue empty, redraw_idle");
redraw_idle_plain(&app.buf, &app.state, &ctx, renderer);
}
}
} else {
ctx.bg_manager.apply_background_event(
runtime_event.runtime_id,
runtime_event.event,
&ctx.session_manager,
);
}
}
_ = sigtstp.recv() => {
renderer.render(UiLine::ClearTransient);
renderer.shutdown();
app.state.on_suspend();
let _ = crossterm::terminal::disable_raw_mode();
unsafe { libc::raise(libc::SIGSTOP); }
}
_ = sigcont.recv() => {
let _ = crossterm::terminal::enable_raw_mode();
app.state.on_resume();
match app.state.phase {
UiPhase::Streaming => {
draw_spinner_now(&mut app.state, &app.buf, &ctx, renderer, app.message_queue.len(), app.menu.selected);
last_spinner_draw = std::time::Instant::now();
}
_ => {
redraw_idle_plain(&app.buf, &app.state, &ctx, renderer);
}
}
}
}
#[cfg(windows)]
tokio::select! {
biased;
Some(()) = win_ctrl_c.recv() => {
if renderer.copy_selection() {
crate::tuix_trace!("KEY", "windows ctrl_c signal -> copy_selection (had selection)");
app.last_ctrl_c_copy = Some(std::time::Instant::now());
} else if matches!(app.state.phase, UiPhase::Streaming) {
crate::tuix_trace!("KEY", "windows ctrl_c signal -> Cancel (streaming)");
ctx.agent.cmd_tx.send(AgentCommand::Cancel).ok();
restore_cancelled_message_to_buf(&mut app, renderer, &ctx);
} else {
crate::tuix_trace!("KEY", "windows ctrl_c signal -> Shutdown");
ctx.agent.cmd_tx.send(AgentCommand::Shutdown).ok();
}
}
_ = deferred_render_tick.tick() => {
renderer.flush_deferred();
}
Some(()) = spin_rx.recv(), if matches!(app.state.phase, UiPhase::Streaming) => {
draw_spinner_now(&mut app.state, &app.buf, &ctx, renderer, app.message_queue.len(), app.menu.selected);
last_spinner_draw = std::time::Instant::now();
}
maybe = ctx.input_rx.recv() => {
let Some(ev) = maybe else { break };
handle_input(&mut app, &mut ctx, renderer, ev)?;
}
Some(()) = ctx.wake_rx.recv(), if matches!(app.state.phase, UiPhase::Idle) => {
redraw_idle_plain(&app.buf, &app.state, &ctx, renderer);
}
Some(ev) = ctx.oauth_event_rx.recv() => {
use oauth_poll::OauthEvent;
let was_modal_open = app.active_modal.is_some();
if was_modal_open {
app.active_modal = None;
renderer.clear_screen();
}
match ev {
OauthEvent::Authorized => {
crate::modals::onboarding_wizard::paint_welcome(&ctx, renderer);
if let Err(e) = crate::event_loop::commands::run_codingplan_flow(renderer, &mut ctx) {
renderer.render(crate::render::UiLine::Error(
format!("CodingPlan 自动领取失败: {e:#}。可运行 /codingplan 手动重试。"),
));
renderer.flush();
}
let dir_display = crate::platform::collapse_home(
&ctx.working_dir.to_string_lossy(),
);
renderer.refresh_welcome_banner(&ctx.model_name, &dir_display);
if !app.setup_hint_shown && should_auto_show_setup(&ctx) {
renderer.render(crate::render::UiLine::CommandOutput(
crate::i18n::t(crate::i18n::Msg::CmdSetupTip).into_owned(),
));
renderer.flush();
app.setup_hint_shown = true;
}
}
OauthEvent::Failed(reason) => {
renderer.render(crate::render::UiLine::Error(
format!(
"登录失败: {reason}。运行 /codingplan 可重试。",
),
));
renderer.flush();
}
}
}
Some(ev) = async {
if let Some(rx) = ctx.mcp_connect_rx.as_mut() {
rx.recv().await
} else {
None
}
}, if ctx.mcp_connect_rx.is_some() => {
use atomcode_core::mcp::{McpConnectEvent, register_mcp_tools_async};
match &ev {
McpConnectEvent::Connected { name } => {
renderer.render(UiLine::CommandOutput(
crate::i18n::t(crate::i18n::Msg::McpServerConnected { name }).into_owned(),
));
if let Some(registry) = &ctx.mcp_registry {
let registry = registry.clone();
let tools = ctx.agent.tool_registry.clone();
let name = name.clone();
let tx = registry.event_sender();
tokio::spawn(async move {
let list_timeout = registry.list_tools_timeout(&name).await;
let server_tools = match tokio::time::timeout(
list_timeout,
registry.list_tools_for_server(&name),
)
.await
{
Ok(v) => v,
Err(_) => {
if let Some(tx) = tx {
let _ = tx.send(McpConnectEvent::Warning {
name,
message: format!(
"tools/list timed out after {}s during auto-registration",
list_timeout.as_secs()
),
});
}
return;
}
};
if !server_tools.is_empty() {
register_mcp_tools_async(&tools, registry, server_tools).await;
}
});
}
}
McpConnectEvent::Failed { name, error } => {
renderer.render(UiLine::Error(
crate::i18n::t(crate::i18n::Msg::McpServerFailed { name, error }).into_owned(),
));
}
McpConnectEvent::Warning { name, message } => {
if message.starts_with("tools:\n")
|| message.contains("tools/list timed out")
|| message.contains("tools/list failed")
{
renderer.render(UiLine::CommandOutput(format!(
" [mcp:{}] {}\n",
name,
message.trim_end()
)));
} else {
crate::tuix_trace!("MCP", "server='{}' warning: {}", name, message);
}
}
}
if let Some(p) = ctx.mcp_reload.as_mut() {
match &ev {
McpConnectEvent::Connected { .. } => {
p.done = p.done.saturating_add(1);
p.connected = p.connected.saturating_add(1)
}
McpConnectEvent::Failed { .. } => {
p.done = p.done.saturating_add(1);
p.failed = p.failed.saturating_add(1)
}
McpConnectEvent::Warning { .. } => {}
}
if p.done >= p.total {
let elapsed_ms = p.started_at.elapsed().as_millis();
renderer.render(UiLine::CommandOutput(format!(
" MCP reload complete: {} connected, {} failed ({}ms)\n",
p.connected, p.failed, elapsed_ms
)));
ctx.mcp_reload = None;
}
}
renderer.flush();
}
Some(ev) = async {
if let Some(rx) = ctx.lsp_connect_rx.as_mut() {
rx.recv().await
} else {
None
}
}, if ctx.lsp_connect_rx.is_some() => {
use atomcode_core::lsp::LspConnectEvent;
match &ev {
LspConnectEvent::Started { command, ext } => {
renderer.render(UiLine::CommandOutput(
crate::i18n::t(crate::i18n::Msg::LspServerStarted { name: command, ext }).into_owned(),
));
}
LspConnectEvent::Failed { command, ext, error } => {
renderer.render(UiLine::Error(
crate::i18n::t(crate::i18n::Msg::LspServerFailed { name: command, ext, error }).into_owned(),
));
}
LspConnectEvent::Warning { ext, message } => {
crate::tuix_trace!("LSP", "ext='{}' warning: {}", ext, message);
}
}
renderer.flush();
}
Some(ev) = ctx.upgrade_rx.recv() => {
handle_upgrade_event(ev, &mut upgrade_last_pct, &mut upgrade_done, &mut ctx, renderer);
if upgrade_done.is_some() { break; }
if matches!(app.state.phase, UiPhase::Idle) {
redraw_idle_plain(&app.buf, &app.state, &ctx, renderer);
}
}
Some(ev) = ctx.plugin_job_rx.recv() => {
handle_plugin_job_event(ev, &mut ctx, &app.state, renderer);
if matches!(app.state.phase, UiPhase::Idle) {
redraw_idle_plain(&app.buf, &app.state, &ctx, renderer);
}
}
maybe = ctx.runtime_event_rx.recv() => {
let Some(runtime_event) = maybe else { break };
if runtime_event.runtime_id == ctx.foreground_runtime_id {
let pre_phase = app.state.phase;
handle_agent_event(runtime_event.event, &mut app.state, &mut app.think, renderer, &mut app.pending_tools, &mut ctx, &mut app.fixissue_pending, &mut app.fixissue_buffer, &mut app.reasoning_buffer, &app.buf);
if pre_phase != app.state.phase {
crate::tuix_trace!("PH", "{:?} -> {:?}", pre_phase, app.state.phase);
}
if matches!(app.state.phase, UiPhase::Streaming)
&& last_spinner_draw.elapsed() >= Duration::from_millis(100)
{
draw_spinner_now(&mut app.state, &app.buf, &ctx, renderer, app.message_queue.len(), app.menu.selected);
last_spinner_draw = std::time::Instant::now();
}
if matches!(app.state.phase, UiPhase::Idle) {
if let Some(queued) = app.message_queue.pop_front() {
crate::tuix_trace!("QUE", "pop_front remaining={}", app.message_queue.len());
renderer.render(UiLine::User(queued.text.clone()));
renderer.flush();
ctx.agent.cmd_tx.send(AgentCommand::SendMessage {
text: queued.text,
images: queued.images,
image_markers: queued.image_markers,
}).ok();
app.state.on_submit();
draw_spinner_now(&mut app.state, &app.buf, &ctx, renderer, app.message_queue.len(), app.menu.selected);
} else {
crate::tuix_trace!("PH", "turn_end -> Idle, queue empty, redraw_idle");
redraw_idle_plain(&app.buf, &app.state, &ctx, renderer);
}
}
} else {
ctx.bg_manager.apply_background_event(
runtime_event.runtime_id,
runtime_event.event,
&ctx.session_manager,
);
}
}
}
if matches!(app.state.phase, UiPhase::Idle) && ctx.agent.cmd_tx.is_closed() {
break;
}
}
spin_task.abort();
let _ = ctx.history.save();
if let Some(exe) = upgrade_done {
Ok(ExitReason::UpgradeRestart { exe })
} else {
Ok(ExitReason::Normal)
}
}
fn refresh_after_cross_process_codingplan_sync(ctx: &mut LoopCtx) {
let current = atomcode_core::coding_plan::read_last_sync();
let advanced = match (current, ctx.monitor_last_sync_seen) {
(Some(new), Some(old)) => new > old,
(Some(_), None) => true, _ => false,
};
if !advanced {
return;
}
ctx.monitor_last_sync_seen = current;
let path = atomcode_core::config::Config::default_path();
if let Ok(fresh) = atomcode_core::config::Config::load(&path) {
ctx.config = fresh;
ctx.runtime_factory.set_config(ctx.config.clone());
if let Some(p) = ctx.config.providers.get(&ctx.config.default_provider) {
ctx.model_name = p.model.clone();
}
let _ = ctx
.agent
.cmd_tx
.send(AgentCommand::ReloadConfig(ctx.config.clone()));
}
if let Ok(mut g) = ctx.monitor_warning.lock() {
*g = None;
}
ctx.monitor_last_check_at = None;
if let Ok(mut g) = ctx.usage_slot.lock() {
*g = None;
}
ctx.usage_last_check_at = None;
}
fn attach_image_to_input(
app: &mut App,
ctx: &mut LoopCtx,
renderer: &mut dyn Renderer,
img_hash: Option<(ImagePart, u64)>,
) -> Result<bool> {
let Some((img, hash)) = img_hash else {
return Ok(false);
};
if !ctx.config.can_handle_attached_images() {
renderer.render(UiLine::Error(
crate::i18n::t(crate::i18n::Msg::ModelNoImageSupport {
model: &ctx.model_name,
})
.into_owned(),
));
renderer.flush();
if matches!(app.state.phase, UiPhase::Idle) {
redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
}
return Ok(true);
}
app.state.session_image_count += 1;
let n = app.state.session_image_count;
app.state.pending_images.push(img.clone());
app.state.pending_image_hashes.push(hash);
app.state.pending_image_markers.push(n);
cache_write_image(&crate::platform::image_cache_dir(), &img, hash);
let marker = format!("[Image #{}]", n);
app.buf.text.insert_str(app.buf.cursor, &marker);
app.buf.cursor += marker.len();
if matches!(app.state.phase, UiPhase::Streaming) {
draw_spinner_now(
&mut app.state,
&app.buf,
ctx,
renderer,
app.message_queue.len(),
app.menu.selected,
);
} else {
redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
}
Ok(true)
}
fn handle_paste_command(
app: &mut App,
ctx: &mut LoopCtx,
renderer: &mut dyn Renderer,
) -> Result<()> {
let img_hash = try_paste_clipboard_image();
if img_hash.is_none() {
renderer.render(UiLine::Error(
crate::i18n::t(crate::i18n::Msg::CmdPasteNoImage).into_owned(),
));
renderer.flush();
if matches!(app.state.phase, UiPhase::Idle) {
redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
}
return Ok(());
}
attach_image_to_input(app, ctx, renderer, img_hash)?;
Ok(())
}
fn handle_input(
app: &mut App,
ctx: &mut LoopCtx,
renderer: &mut dyn Renderer,
ev: InputEvent,
) -> Result<()> {
use crate::modals::ModalAction;
refresh_after_cross_process_codingplan_sync(ctx);
crate::tuix_trace!(
"IN",
"phase={:?} modal={} qlen={} ev={}",
app.state.phase,
app.active_modal.is_some(),
app.message_queue.len(),
match &ev {
InputEvent::Paste(t) => format!("paste({})", t.len()),
InputEvent::Eof => "eof".into(),
InputEvent::Key(k) => format!("key({:?},{:?})", k.kind, k.code),
InputEvent::Resize(w, h) => format!("resize({}x{})", w, h),
InputEvent::MouseScroll(d) => format!("mouse_scroll({})", d),
InputEvent::MouseDown { col, row } => format!("mouse_down({},{})", col, row),
InputEvent::MouseDrag { col, row } => format!("mouse_drag({},{})", col, row),
InputEvent::MouseUp => "mouse_up".into(),
}
);
match ev {
InputEvent::MouseScroll(delta) => {
renderer.scroll_body(delta);
}
InputEvent::MouseDown { col, row } => {
renderer.begin_selection(col, row);
}
InputEvent::MouseDrag { col, row } => {
renderer.update_selection(col, row);
}
InputEvent::MouseUp => {
renderer.end_selection();
}
InputEvent::Resize(mut cols, mut rows) => {
let mut deferred: Vec<InputEvent> = Vec::new();
while let Ok(next) = ctx.input_rx.try_recv() {
match next {
InputEvent::Resize(w, h) => {
cols = w;
rows = h;
}
other => deferred.push(other),
}
}
renderer.on_resize(cols, rows);
for ev in deferred {
handle_input(app, ctx, renderer, ev)?;
}
}
InputEvent::Paste(text) => {
if matches!(app.state.phase, UiPhase::Idle) {
if let Some(modal) = app.active_modal.as_mut() {
let action =
modal.handle_paste(&text, &mut app.buf, &mut app.state, ctx, renderer)?;
if matches!(action, crate::modals::ModalAction::Close) {
app.active_modal = None;
redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
}
return Ok(());
}
}
if matches!(app.state.phase, UiPhase::Idle | UiPhase::Streaming) {
let image_paste: Option<(ImagePart, u64)> = if text.trim().is_empty() {
try_paste_clipboard_image()
} else {
try_attach_image_from_path(&text)
};
if attach_image_to_input(app, ctx, renderer, image_paste)? {
return Ok(());
}
app.buf.insert_paste(text);
if matches!(app.state.phase, UiPhase::Streaming) {
draw_spinner_now(
&mut app.state,
&app.buf,
ctx,
renderer,
app.message_queue.len(),
app.menu.selected,
);
} else {
redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
}
}
}
InputEvent::Eof => {}
InputEvent::Key(KeyEvent {
kind: KeyEventKind::Press | KeyEventKind::Repeat,
code,
modifiers,
..
}) => {
if matches!(app.state.phase, UiPhase::Idle)
&& code == KeyCode::Char('c')
&& modifiers.contains(crossterm::event::KeyModifiers::CONTROL)
&& app.active_modal.is_some()
{
app.active_modal = None;
ctx.agent.cmd_tx.send(AgentCommand::Shutdown).ok();
return Ok(());
}
if matches!(app.state.phase, UiPhase::Idle) {
if let Some(modal) = app.active_modal.as_mut() {
let action = modal.handle_key(
code,
modifiers,
&mut app.buf,
&mut app.state,
ctx,
renderer,
)?;
if matches!(action, ModalAction::Close) {
app.active_modal = None;
if let Some(draft) = ctx.pending_new_issue.take() {
match atomcode_core::atomgit::Client::from_stored_auth().and_then(|c| {
c.create_issue(&draft.owner, &draft.repo, &draft.title, &draft.body)
}) {
Ok(created) => {
let shown_url = created.html_url.clone().unwrap_or_else(|| {
format!(
"https://atomgit.com/{}/{}/issues/{}",
draft.owner, draft.repo, created.number
)
});
renderer.render(UiLine::CommandOutput(
crate::i18n::t(crate::i18n::Msg::IssueCreated {
number: created.number,
title: &created.title,
url: &shown_url,
}).into_owned(),
));
}
Err(e) => {
renderer.render(UiLine::CommandOutput(
crate::i18n::t(crate::i18n::Msg::IssueCreateFailed {
error: &format!("{:#}", e),
}).into_owned(),
));
}
}
renderer.flush();
}
if std::mem::take(&mut ctx.pending_run_codingplan) {
crate::event_loop::commands::run_codingplan_flow(renderer, ctx)?;
}
if std::mem::take(&mut ctx.pending_open_provider_wizard) {
let pw = crate::modals::ProviderWizard::MainMenu { selected: 0 };
app.active_modal = Some(Box::new(pw));
if let Some(m) = app.active_modal.as_mut() {
m.draw(&app.buf, &app.state, ctx, renderer);
}
return Ok(());
}
redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
}
return Ok(());
}
}
if code == crossterm::event::KeyCode::Char('c')
&& modifiers.contains(crossterm::event::KeyModifiers::CONTROL)
&& !modifiers.contains(crossterm::event::KeyModifiers::SHIFT)
&& !modifiers.contains(crossterm::event::KeyModifiers::ALT)
{
#[cfg(windows)]
if let Some(ts) = app.last_ctrl_c_copy.take() {
if ts.elapsed() < Duration::from_millis(500) {
crate::tuix_trace!("KEY", "ctrl+c keyboard echo suppressed (OS signal already handled copy)");
return Ok(());
}
}
if renderer.copy_selection() {
return Ok(());
}
}
if matches!(
app.state.phase,
UiPhase::Idle | UiPhase::Streaming
) && code == crossterm::event::KeyCode::Char('v')
&& modifiers.contains(crossterm::event::KeyModifiers::CONTROL)
&& !modifiers.contains(crossterm::event::KeyModifiers::SHIFT)
&& !modifiers.contains(crossterm::event::KeyModifiers::ALT)
{
let img_hash = try_paste_clipboard_image();
if attach_image_to_input(app, ctx, renderer, img_hash)? {
return Ok(());
}
if let Some(text) = try_paste_clipboard_text() {
return handle_input(app, ctx, renderer, InputEvent::Paste(text));
}
return Ok(());
}
if let Some(handled) = handle_scroll_key(code, modifiers, renderer, &app.buf) {
if handled {
return Ok(());
}
}
match app.state.phase {
UiPhase::Idle => handle_idle_key(app, ctx, renderer, code, modifiers)?,
UiPhase::Streaming => handle_streaming_key(app, ctx, renderer, code, modifiers)?,
UiPhase::Approval => handle_approval_key(app, ctx, renderer, code, modifiers)?,
UiPhase::Suspended => {}
}
}
InputEvent::Key(_) => {}
}
Ok(())
}
fn handle_scroll_key(
code: crossterm::event::KeyCode,
modifiers: crossterm::event::KeyModifiers,
renderer: &mut dyn crate::render::Renderer,
buf: &Buffer,
) -> Option<bool> {
use crossterm::event::{KeyCode, KeyModifiers};
let buf_empty = buf.text.is_empty();
let has_shift = modifiers.contains(KeyModifiers::SHIFT);
match code {
KeyCode::PageUp => {
renderer.scroll_body(-10);
Some(true)
}
KeyCode::PageDown => {
renderer.scroll_body(10);
Some(true)
}
KeyCode::Up if has_shift => {
renderer.scroll_body(-1);
Some(true)
}
KeyCode::Down if has_shift => {
renderer.scroll_body(1);
Some(true)
}
KeyCode::Home if buf_empty && modifiers.is_empty() => {
renderer.scroll_body_to_top();
Some(true)
}
KeyCode::End if buf_empty && modifiers.is_empty() => {
renderer.scroll_body_to_bottom();
Some(true)
}
_ => None,
}
}
pub struct MenuState {
pub selected: usize,
}
impl MenuState {
pub fn new() -> Self {
Self { selected: 0 }
}
}
pub use crate::modals::ModelPicker;
pub use crate::modals::SessionPicker;
pub use crate::modals::ProviderWizard;
fn build_menu_items(
buf: &str,
cursor: usize,
commands: &CommandRegistry,
custom: &atomcode_core::commands::CustomCommandRegistry,
skill_registry: Option<&std::sync::RwLock<atomcode_core::skill::SkillRegistry>>,
file_index: Option<&file_index::FileIndex>,
) -> Option<Vec<(String, String)>> {
if let (Some(idx), Some(token)) =
(file_index, file_index::detect_at_mention(buf, cursor))
{
let (scope_dir, filter) = file_index::split_token(&token);
let entries = idx.filter(&scope_dir, &filter);
if entries.is_empty() {
return None;
}
return Some(
entries
.into_iter()
.map(|e| (e.rel_path, String::new()))
.collect(),
);
}
if !buf.starts_with('/') {
return None;
}
if let Some(after) = buf.strip_prefix("/skills ") {
if after.contains(char::is_whitespace) {
return None;
}
let prefix_lower = after.to_ascii_lowercase();
let mut items: Vec<(String, String)> = Vec::new();
if let Some(reg) = skill_registry {
if let Ok(reg) = reg.read() {
let skills: Vec<_> = reg.user_invocable().collect();
for skill in &skills {
let bare = skill
.name
.split_once(':')
.map(|(_, s)| s)
.unwrap_or(skill.name.as_str());
let full_lower = skill.name.to_ascii_lowercase();
let bare_lower = bare.to_ascii_lowercase();
if bare_lower.starts_with(&prefix_lower)
|| full_lower.starts_with(&prefix_lower)
{
let bare_is_unique = skills.iter().all(|other| {
other.name == skill.name
|| other
.name
.split_once(':')
.map(|(_, s)| s)
.unwrap_or(other.name.as_str())
!= bare
});
let display = if bare_is_unique {
bare.to_string()
} else {
skill.name.clone()
};
items.push((display, skill.description.clone()));
}
}
}
}
items.sort_by(|a, b| a.0.cmp(&b.0));
return if items.is_empty() { None } else { Some(items) };
}
let rest = &buf[1..];
if rest.contains(char::is_whitespace) {
return None;
}
let prefix_lower = rest.to_ascii_lowercase();
let mut matches: Vec<(String, String)> = commands
.matching_prefix(rest)
.into_iter()
.map(|c| {
let desc = crate::commands::cmd_desc_i18n(c.name)
.map(|cow| cow.into_owned())
.unwrap_or_else(|| c.desc.to_string());
(c.name.to_string(), desc)
})
.collect();
for (name, desc) in custom.command_names_and_descriptions() {
if name.starts_with(&prefix_lower) && !matches.iter().any(|(n, _)| *n == name) {
matches.push((name, desc));
}
}
let _ = skill_registry; if matches.is_empty() {
None
} else {
Some(matches)
}
}
fn handle_idle_key(
app: &mut App,
ctx: &mut LoopCtx,
renderer: &mut dyn Renderer,
code: KeyCode,
modifiers: crossterm::event::KeyModifiers,
) -> Result<()> {
let menu_items = if app.buf.is_in_history() {
None
} else {
build_menu_items(&app.buf.text, app.buf.cursor, &ctx.commands, &ctx.custom_commands, Some(&ctx.skill_registry), Some(&ctx.file_index))
};
if let Some(items) = &menu_items {
if app.menu.selected >= items.len() {
app.menu.selected = items.len() - 1;
}
match (code, modifiers) {
(KeyCode::Up, _) => {
app.menu.selected = if app.menu.selected == 0 {
items.len() - 1
} else {
app.menu.selected - 1
};
redraw_with_menu(
&app.buf,
items,
app.menu.selected,
&app.state,
ctx,
renderer,
);
return Ok(());
}
(KeyCode::Down, _) => {
app.menu.selected = (app.menu.selected + 1) % items.len();
redraw_with_menu(
&app.buf,
items,
app.menu.selected,
&app.state,
ctx,
renderer,
);
return Ok(());
}
(KeyCode::Enter | KeyCode::Tab, m)
if !m.contains(crossterm::event::KeyModifiers::SHIFT) =>
{
if !items.is_empty() {
if let Some((at_pos, end)) =
file_index::detect_at_mention_range(&app.buf.text, app.buf.cursor)
{
let selected_path = items[app.menu.selected].0.clone();
let replacement = format!("@{} ", selected_path);
app.buf.text.replace_range(at_pos..end, &replacement);
app.buf.cursor = at_pos + replacement.len();
app.menu.selected = 0;
redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
return Ok(());
}
}
let name = items[app.menu.selected].0.clone();
let needs_args = ctx
.commands
.find(&name)
.map(|c| c.needs_args)
.unwrap_or(false);
app.menu.selected = 0;
if needs_args {
app.buf.text = format!("/{} ", name);
app.buf.cursor = app.buf.text.len();
if name == "skills" {
if let Some(items) = build_menu_items(
&app.buf.text,
app.buf.cursor,
&ctx.commands,
&ctx.custom_commands,
Some(&ctx.skill_registry),
Some(&ctx.file_index),
) {
app.menu.selected = 0;
redraw_with_menu(&app.buf, &items, 0, &app.state, ctx, renderer);
return Ok(());
}
renderer.render(UiLine::CommandOutput(
" \u{24d8} No user-invocable skills installed yet.\n \
\u{2022} Drop SKILL.md into ~/.atomcode/skills/<name>/ \n \
(Windows: %USERPROFILE%\\.atomcode\\skills\\<name>\\)\n \
\u{2022} Or install a plugin that ships skills via /plugin install <git-url>\n\n"
.into(),
));
}
redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
return Ok(());
}
let in_skills_sub_mode = app.buf.text.starts_with("/skills ");
if in_skills_sub_mode {
app.buf.text = format!("/skills {} ", name);
app.buf.cursor = app.buf.text.len();
redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
return Ok(());
}
if code == KeyCode::Tab {
app.buf.text = format!("/{} ", name);
app.buf.cursor = app.buf.text.len();
redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
return Ok(());
}
let committed = format!("/{}", name);
renderer.render(UiLine::ClearTransient);
renderer.render(UiLine::User(committed.clone()));
app.buf.text.clear();
app.buf.cursor = 0;
if let Some((cmd, arg)) = parse_slash_line(&committed) {
if cmd.eq_ignore_ascii_case("paste") {
handle_paste_command(app, ctx, renderer)?;
} else {
execute_slash_command(
cmd,
arg,
&mut app.state,
ctx,
renderer,
&mut app.active_modal,
&mut app.fixissue_pending,
&mut app.fixissue_buffer,
)?;
}
if matches!(app.state.phase, UiPhase::Idle) {
redraw_after_slash(&app.buf, &app.state, ctx, &app.active_modal, renderer);
}
}
return Ok(());
}
(KeyCode::Esc, _) => {
app.buf.text.clear();
app.buf.cursor = 0;
app.menu.selected = 0;
redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
return Ok(());
}
_ => {} }
}
if code == KeyCode::Tab && menu_items.is_none() {
app.state.agent_mode = app.state.agent_mode.toggle();
let is_plan = matches!(app.state.agent_mode, crate::state::AgentMode::Plan);
ctx.agent
.cmd_tx
.send(AgentCommand::SetPlanMode(is_plan))
.ok();
renderer.render(UiLine::CommandOutput(format!(
" Switched to {} mode.\n",
app.state.agent_mode.label()
)));
renderer.flush();
redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
return Ok(());
}
if code == KeyCode::Char('v') && modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
if let Some((img, hash)) = try_paste_clipboard_image() {
if !ctx.config.can_handle_attached_images() {
renderer.render(UiLine::Error(
crate::i18n::t(crate::i18n::Msg::ModelNoImageSupport {
model: &ctx.model_name,
})
.into_owned(),
));
renderer.flush();
redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
return Ok(());
}
app.state.session_image_count += 1;
let n = app.state.session_image_count;
app.state.pending_images.push(img.clone());
app.state.pending_image_hashes.push(hash);
app.state.pending_image_markers.push(n);
cache_write_image(&crate::platform::image_cache_dir(), &img, hash);
let marker = format!("[Image #{}]", n);
app.buf.text.insert_str(app.buf.cursor, &marker);
app.buf.cursor += marker.len();
redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
return Ok(());
}
}
if modifiers.is_empty() {
match code {
KeyCode::Up if app.buf.cursor_line_up() => {
redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
return Ok(());
}
KeyCode::Down if app.buf.cursor_line_down() => {
redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
return Ok(());
}
_ => {}
}
}
let action = classify(code, modifiers);
let result = app.buf.apply(action, ctx.history.entries(), &ctx.commands);
sync_recalled_attachments(&mut app.state, &app.buf, ctx.history.entries());
crate::tuix_trace!(
"KEY",
"idle result={} buf_len={} cursor={}",
match &result {
BufferResult::NoOp => "NoOp",
BufferResult::Redraw => "Redraw",
BufferResult::Commit(_) => "Commit",
BufferResult::Exit => "Exit",
},
app.buf.text.len(),
app.buf.cursor
);
if !matches!(result, BufferResult::Exit) {
app.exit_pending = None;
}
match result {
BufferResult::NoOp => {}
BufferResult::Redraw => {
let items = if app.buf.is_in_history() {
None
} else {
build_menu_items(&app.buf.text, app.buf.cursor, &ctx.commands, &ctx.custom_commands, Some(&ctx.skill_registry), Some(&ctx.file_index))
};
if let Some(items) = items {
if app.menu.selected >= items.len() {
app.menu.selected = 0;
}
redraw_with_menu(
&app.buf,
&items,
app.menu.selected,
&app.state,
ctx,
renderer,
);
} else {
app.menu.selected = 0;
redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
}
}
BufferResult::Commit(line) => {
renderer.render(UiLine::ClearTransient);
app.buf.text.clear();
app.buf.cursor = 0;
app.menu.selected = 0;
let as_slash = parse_slash_line(&line).filter(|(cmd, _)| {
ctx.commands.find(cmd).is_some()
|| ctx.custom_commands.get(&cmd.to_ascii_lowercase()).is_some()
|| ctx
.skill_registry
.read()
.ok()
.and_then(|r| r.get(cmd).map(|s| s.user_invocable))
.unwrap_or(false)
});
if let Some((cmd, arg)) = as_slash {
renderer.render(UiLine::User(line.clone()));
if cmd.eq_ignore_ascii_case("paste") {
handle_paste_command(app, ctx, renderer)?;
} else {
execute_slash_command(
cmd,
arg,
&mut app.state,
ctx,
renderer,
&mut app.active_modal,
&mut app.fixissue_pending,
&mut app.fixissue_buffer,
)?;
}
if matches!(app.state.phase, UiPhase::Idle) {
redraw_after_slash(&app.buf, &app.state, ctx, &app.active_modal, renderer);
} else if matches!(app.state.phase, UiPhase::Approval) {
redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
}
app.buf.clear_pastes();
} else {
let cache_dir = crate::platform::image_cache_dir();
let mut line = line; for n in hydrate_recalled_attachments(&mut app.state, &mut line, &cache_dir) {
renderer.render(UiLine::Warning(n));
}
renderer.render(UiLine::User(line.clone()));
let expanded = app.buf.expand_pastes(&line);
app.buf.clear_pastes();
app.state.last_submitted_message = Some(expanded.clone());
let pending = std::mem::take(&mut app.state.pending_images);
let pending_markers = std::mem::take(&mut app.state.pending_image_markers);
let pending_hashes = std::mem::take(&mut app.state.pending_image_hashes);
let mut images: Vec<ImagePart> = Vec::with_capacity(pending.len());
let mut kept_markers: Vec<usize> = Vec::with_capacity(pending.len());
let mut kept_refs: Vec<crate::input::history::HistoryImageRef> =
Vec::with_capacity(pending.len());
for ((img, n), hash) in pending
.into_iter()
.zip(pending_markers.into_iter())
.zip(pending_hashes.into_iter())
{
if line.contains(&format!("[Image #{}]", n)) {
renderer.render(UiLine::ImageAttachment(n));
kept_refs.push(crate::input::history::HistoryImageRef {
hash: format!("{:016x}", hash),
mt: img.media_type.clone(),
n,
});
images.push(img);
kept_markers.push(n);
}
}
ctx.history.push(crate::input::history::HistoryEntry {
text: line.clone(),
images: kept_refs,
});
ctx.agent
.cmd_tx
.send(AgentCommand::SendMessage {
text: expanded,
images,
image_markers: kept_markers,
})
.ok();
app.state.on_submit();
if monitor::is_codingplan_provider(&ctx.config.default_provider) {
let cooled = ctx
.monitor_last_check_at
.map(|t| t.elapsed() >= monitor::CHECK_COOLDOWN)
.unwrap_or(true);
if cooled {
ctx.monitor_last_check_at = Some(std::time::Instant::now());
monitor::spawn_check(
ctx.config.clone(),
ctx.model_name.clone(),
ctx.monitor_warning.clone(),
ctx.wake_tx.clone(),
);
}
}
}
}
BufferResult::Exit => {
let now = std::time::Instant::now();
let armed = app
.exit_pending
.is_some_and(|t| now.duration_since(t) <= CTRL_C_EXIT_WINDOW);
if armed {
ctx.agent.cmd_tx.send(AgentCommand::Shutdown).ok();
} else {
app.exit_pending = Some(now);
renderer.render(UiLine::CommandOutput(
crate::i18n::t(crate::i18n::Msg::CtrlCAgainToExit).into_owned(),
));
renderer.flush();
redraw_idle_plain(&app.buf, &app.state, ctx, renderer);
}
}
}
Ok(())
}
fn redraw_with_menu(
buf: &Buffer,
items: &[(String, String)],
selected: usize,
state: &UiState,
ctx: &LoopCtx,
renderer: &mut dyn Renderer,
) {
let kind = if file_index::detect_at_mention_range(&buf.text, buf.cursor).is_some() {
crate::render::MenuKind::AtMention
} else {
crate::render::MenuKind::SlashCommand
};
let payload = crate::render::MenuPayload {
items: items.to_vec(),
selected,
kind,
};
let attachments = compute_input_attachments(state, &buf.text);
renderer.render(UiLine::InputPrompt {
buf: buf.text.clone(),
cursor_byte: buf.cursor,
menu: Some(payload),
status: build_status(state, ctx),
attachments,
});
renderer.flush();
}
pub(crate) fn sync_recalled_attachments(
state: &mut UiState,
buf: &Buffer,
history: &[crate::input::history::HistoryEntry],
) {
match buf.history_idx() {
Some(i) if i < history.len() => {
state.pending_recalled_attachments = history[i].images.clone();
}
_ => {
state
.pending_recalled_attachments
.retain(|r| buf.text.contains(&format!("[Image #{}]", r.n)));
}
}
}
fn redraw_idle_plain(buf: &Buffer, state: &UiState, ctx: &LoopCtx, renderer: &mut dyn Renderer) {
let attachments = compute_input_attachments(state, &buf.text);
renderer.render(UiLine::InputPrompt {
buf: buf.text.clone(),
cursor_byte: buf.cursor,
menu: None,
status: build_status(state, ctx),
attachments,
});
renderer.flush();
}
pub(crate) fn should_auto_show_onboarding(ctx: &LoopCtx) -> bool {
if ctx.is_plain_renderer {
return false;
}
ctx.config.providers.is_empty() && atomcode_core::auth::get_stored_auth().is_none()
}
fn should_auto_show_setup(ctx: &LoopCtx) -> bool {
let state = atomcode_core::setup::state::load_setup_state(&ctx.working_dir);
if state.is_none() {
return true; }
let skill_dir = atomcode_core::config::Config::config_dir()
.join("skills")
.join("atomcode-automation-recommender");
!skill_dir.exists()
}
fn parse_already_latest_versions(s: &str) -> Option<(&str, &str)> {
let after_on = s.strip_prefix("already on ")?;
let (current, rest) = after_on.split_once(" (latest is ")?;
let latest = rest.strip_suffix(". Pass --force to reinstall.")?;
let latest = latest.strip_suffix(')')?;
Some((current, latest))
}
#[cfg(test)]
mod parse_already_latest_versions_tests {
use super::parse_already_latest_versions;
#[test]
fn extracts_both_versions() {
let s = "already on v4.22.2 (latest is v4.22.2). Pass --force to reinstall.";
assert_eq!(parse_already_latest_versions(s), Some(("v4.22.2", "v4.22.2")));
}
#[test]
fn rejects_unrelated_strings() {
assert!(parse_already_latest_versions("something else entirely").is_none());
}
}
fn redraw_after_slash(
buf: &Buffer,
state: &UiState,
ctx: &LoopCtx,
active_modal: &Option<Box<dyn crate::modals::Modal>>,
renderer: &mut dyn Renderer,
) {
if let Some(modal) = active_modal.as_ref() {
modal.draw(buf, state, ctx, renderer);
} else {
redraw_idle_plain(buf, state, ctx, renderer);
}
}
pub(crate) fn reload_plugins(ctx: &mut LoopCtx) -> (usize, Vec<String>) {
let mut loaded = 0usize;
let mut warnings = Vec::new();
if let Ok(mut guard) = ctx.skill_registry.write() {
warnings = guard.reload(&ctx.working_dir);
loaded = guard.all().count();
}
ctx.custom_commands = atomcode_core::commands::CustomCommandRegistry::load(&ctx.working_dir);
let _ = ctx
.agent
.cmd_tx
.send(atomcode_core::agent::AgentCommand::ReloadHooks);
(loaded, warnings)
}
pub(crate) fn save_and_reload(ctx: &mut LoopCtx, renderer: &mut dyn Renderer) {
let path = Config::default_path();
match ctx.config.save(&path) {
Ok(()) => {
ctx.runtime_factory.set_config(ctx.config.clone());
let _ = ctx
.agent
.cmd_tx
.send(AgentCommand::ReloadConfig(ctx.config.clone()));
}
Err(e) => {
renderer.render(UiLine::Error(crate::i18n::t(crate::i18n::Msg::ConfigSaveFailed { error: &format!("{}", e) }).into_owned()));
renderer.flush();
}
}
}
fn restore_cancelled_message_to_buf(app: &mut App, renderer: &mut dyn Renderer, ctx: &LoopCtx) {
app.message_queue.clear();
if let Some(msg) = app.state.last_submitted_message.take() {
app.buf.text = msg;
app.buf.cursor = app.buf.text.len();
app.menu.selected = 0;
draw_spinner_now(
&mut app.state,
&app.buf,
ctx,
renderer,
app.message_queue.len(),
app.menu.selected,
);
}
}
fn handle_streaming_key(
app: &mut App,
ctx: &mut LoopCtx,
renderer: &mut dyn Renderer,
code: KeyCode,
modifiers: crossterm::event::KeyModifiers,
) -> Result<()> {
if code == KeyCode::Char('o') && modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
app.state.toggle_tool_output();
let status = if app.state.show_tool_output {
" ○ Verbose mode enabled (tool output + reasoning visible) (Ctrl+O to hide)\n"
} else {
" ○ Verbose mode disabled (Ctrl+O to show tool output + reasoning)\n"
};
renderer.render(UiLine::CommandOutput(status.to_string()));
renderer.flush();
draw_spinner_now(
&mut app.state,
&app.buf,
ctx,
renderer,
app.message_queue.len(),
app.menu.selected,
);
return Ok(());
}
if code == KeyCode::Char('c') && modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
ctx.agent.cmd_tx.send(AgentCommand::Cancel).ok();
restore_cancelled_message_to_buf(app, renderer, ctx);
return Ok(());
}
if code == KeyCode::Esc {
ctx.agent.cmd_tx.send(AgentCommand::Cancel).ok();
restore_cancelled_message_to_buf(app, renderer, ctx);
return Ok(());
}
let menu_items = build_menu_items(&app.buf.text, app.buf.cursor, &ctx.commands, &ctx.custom_commands, Some(&ctx.skill_registry), Some(&ctx.file_index));
if let Some(items) = &menu_items {
if app.menu.selected >= items.len() {
app.menu.selected = items.len() - 1;
}
match code {
KeyCode::Up => {
app.menu.selected = app.menu.selected.saturating_sub(1);
draw_spinner_now(
&mut app.state,
&app.buf,
ctx,
renderer,
app.message_queue.len(),
app.menu.selected,
);
return Ok(());
}
KeyCode::Down => {
if app.menu.selected + 1 < items.len() {
app.menu.selected += 1;
}
draw_spinner_now(
&mut app.state,
&app.buf,
ctx,
renderer,
app.message_queue.len(),
app.menu.selected,
);
return Ok(());
}
KeyCode::Esc => {
app.buf.text.clear();
app.buf.cursor = 0;
app.menu.selected = 0;
draw_spinner_now(
&mut app.state,
&app.buf,
ctx,
renderer,
app.message_queue.len(),
app.menu.selected,
);
return Ok(());
}
_ => {} }
}
if modifiers.is_empty() {
match code {
KeyCode::Up if app.buf.cursor_line_up() => {
draw_spinner_now(
&mut app.state,
&app.buf,
ctx,
renderer,
app.message_queue.len(),
app.menu.selected,
);
return Ok(());
}
KeyCode::Down if app.buf.cursor_line_down() => {
draw_spinner_now(
&mut app.state,
&app.buf,
ctx,
renderer,
app.message_queue.len(),
app.menu.selected,
);
return Ok(());
}
_ => {}
}
}
let action = classify(code, modifiers);
let apply_result = app.buf.apply(action, ctx.history.entries(), &ctx.commands);
sync_recalled_attachments(&mut app.state, &app.buf, ctx.history.entries());
match apply_result {
BufferResult::NoOp => {}
BufferResult::Redraw => {
if let Some(items) = build_menu_items(&app.buf.text, app.buf.cursor, &ctx.commands, &ctx.custom_commands, Some(&ctx.skill_registry), Some(&ctx.file_index)) {
if app.menu.selected >= items.len() {
app.menu.selected = 0;
}
} else {
app.menu.selected = 0;
}
draw_spinner_now(
&mut app.state,
&app.buf,
ctx,
renderer,
app.message_queue.len(),
app.menu.selected,
);
}
BufferResult::Commit(line) => {
let bg_background_current = parse_slash_line(&line)
.map(|(cmd, arg)| cmd.eq_ignore_ascii_case("bg") && arg.trim().is_empty())
.unwrap_or(false);
if bg_background_current {
commands::execute_slash_command(
"bg",
"",
&mut app.state,
ctx,
renderer,
&mut app.active_modal,
&mut app.fixissue_pending,
&mut app.fixissue_buffer,
)?;
app.message_queue.clear();
app.pending_tools.clear();
app.think.reset();
app.reasoning_buffer.clear();
app.buf.text.clear();
app.buf.cursor = 0;
app.menu.selected = 0;
return Ok(());
}
let is_known_slash = parse_slash_line(&line)
.map(|(cmd, _)| ctx.commands.find(cmd).is_some())
.unwrap_or(false);
if is_known_slash {
renderer.render(UiLine::CommandOutput(
" (slash commands are disabled while a turn is running)\n".into(),
));
renderer.flush();
app.buf.text.clear();
app.buf.cursor = 0;
app.menu.selected = 0;
draw_spinner_now(
&mut app.state,
&app.buf,
ctx,
renderer,
app.message_queue.len(),
app.menu.selected,
);
return Ok(());
}
let mut line = line;
let cache_dir_for_hydrate = crate::platform::image_cache_dir();
for n in hydrate_recalled_attachments(&mut app.state, &mut line, &cache_dir_for_hydrate) {
renderer.render(UiLine::Warning(n));
}
let expanded = app.buf.expand_pastes(&line);
let pending = std::mem::take(&mut app.state.pending_images);
let pending_markers = std::mem::take(&mut app.state.pending_image_markers);
let pending_hashes = std::mem::take(&mut app.state.pending_image_hashes);
let mut q_images: Vec<ImagePart> = Vec::with_capacity(pending.len());
let mut q_markers: Vec<usize> = Vec::with_capacity(pending.len());
let mut q_refs: Vec<crate::input::history::HistoryImageRef> =
Vec::with_capacity(pending.len());
for ((img, n), hash) in pending
.into_iter()
.zip(pending_markers.into_iter())
.zip(pending_hashes.into_iter())
{
if line.contains(&format!("[Image #{}]", n)) {
renderer.render(UiLine::ImageAttachment(n));
q_refs.push(crate::input::history::HistoryImageRef {
hash: format!("{:016x}", hash),
mt: img.media_type.clone(),
n,
});
q_images.push(img);
q_markers.push(n);
}
}
ctx.history.push(crate::input::history::HistoryEntry {
text: line.clone(),
images: q_refs,
});
app.message_queue.push_back(crate::state::QueuedMessage {
text: expanded,
images: q_images,
image_markers: q_markers,
});
crate::tuix_trace!("QUE", "push_back len={}", app.message_queue.len());
app.buf.text.clear();
app.buf.cursor = 0;
app.buf.clear_pastes();
renderer.render(UiLine::CommandOutput(format!(" ↳ queued: {}\n", line)));
renderer.flush();
draw_spinner_now(
&mut app.state,
&app.buf,
ctx,
renderer,
app.message_queue.len(),
app.menu.selected,
);
}
BufferResult::Exit => {
ctx.agent.cmd_tx.send(AgentCommand::Cancel).ok();
restore_cancelled_message_to_buf(app, renderer, ctx);
}
}
Ok(())
}
fn handle_approval_key(
app: &mut App,
ctx: &mut LoopCtx,
renderer: &mut dyn Renderer,
code: KeyCode,
modifiers: crossterm::event::KeyModifiers,
) -> Result<()> {
if code == KeyCode::Char('c') && modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
let now = std::time::Instant::now();
let armed = app
.exit_pending
.is_some_and(|t| now.duration_since(t) <= CTRL_C_EXIT_WINDOW);
if armed {
ctx.agent.cmd_tx.send(AgentCommand::Shutdown).ok();
} else {
app.exit_pending = Some(now);
renderer.pop_approval_prompt();
ctx.agent.cmd_tx.send(AgentCommand::DenyTool).ok();
app.state.on_approval_resolved();
renderer.render(UiLine::CommandOutput(
crate::i18n::t(crate::i18n::Msg::CtrlCAgainToExit).into_owned(),
));
renderer.flush();
}
return Ok(());
}
app.exit_pending = None;
let cmd = match code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => AgentCommand::ApproveTool,
KeyCode::Char('a') | KeyCode::Char('A') => AgentCommand::ApproveToolAlways,
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => AgentCommand::DenyTool,
_ => return Ok(()),
};
renderer.pop_approval_prompt();
ctx.agent.cmd_tx.send(cmd).ok();
app.state.on_approval_resolved();
Ok(())
}
pub(super) fn handle_plugin_job_event(
ev: atomcode_core::plugin::PluginJobEvent,
ctx: &mut LoopCtx,
state: &crate::state::UiState,
renderer: &mut dyn Renderer,
) {
use atomcode_core::plugin::PluginJobEvent;
match ev {
PluginJobEvent::MarketplaceAdded(info) => {
let _ = reload_plugins(ctx);
renderer.render(UiLine::CommandOutput(format!(
" marketplace `{}` added at {} ({} plugins)\n",
info.name,
&info.git_commit[..7.min(info.git_commit.len())],
info.plugins.len()
)));
}
PluginJobEvent::MarketplaceUpdated(info) => {
let _ = reload_plugins(ctx);
renderer.render(UiLine::CommandOutput(format!(
" marketplace `{}` updated to {}\n",
info.name,
&info.git_commit[..7.min(info.git_commit.len())]
)));
}
PluginJobEvent::PluginInstalled(info) => {
let (loaded, warnings) = reload_plugins(ctx);
if state.show_tool_output {
for w in &warnings {
renderer.render(UiLine::CommandOutput(format!(" {}\n", w)));
}
}
let hint = if warnings.is_empty() || state.show_tool_output {
String::new()
} else {
" (Ctrl+O for details)".to_string()
};
renderer.render(UiLine::CommandOutput(format!(
" installed `{}@{}` — {} skills loaded, {} skipped{}\n",
info.plugin,
info.marketplace,
loaded,
warnings.len(),
hint,
)));
}
PluginJobEvent::Failed { op, msg } => {
renderer.render(UiLine::Error(format!("{}: {}", op, msg)));
}
}
renderer.flush();
}
pub(super) fn handle_upgrade_event(
ev: atomcode_core::self_update::UpgradeEvent,
last_pct: &mut i32,
done: &mut Option<std::path::PathBuf>,
ctx: &mut LoopCtx,
renderer: &mut dyn Renderer,
) {
use atomcode_core::self_update::UpgradeEvent;
match ev {
UpgradeEvent::ManifestFetched { version } => {
*last_pct = -1;
renderer.render(UiLine::CommandOutput(
crate::i18n::t(crate::i18n::Msg::UpgradeManifestFetched { version: &version }).into_owned(),
));
}
UpgradeEvent::Downloading { bytes, total } => {
let pct = if total == 0 {
0
} else {
((bytes * 100) / total) as i32
};
if pct != *last_pct {
*last_pct = pct;
if pct == 25 || pct == 50 || pct == 75 || pct == 100 {
renderer.render(UiLine::CommandOutput(
crate::i18n::t(crate::i18n::Msg::UpgradeDownloading { pct, bytes, total }).into_owned(),
));
}
}
}
UpgradeEvent::Verifying => {
renderer.render(UiLine::CommandOutput(
crate::i18n::t(crate::i18n::Msg::UpgradeVerifying).into_owned(),
));
}
UpgradeEvent::Replacing => {
renderer.render(UiLine::CommandOutput(
crate::i18n::t(crate::i18n::Msg::UpgradeReplacing).into_owned(),
));
}
UpgradeEvent::Done { version, backup, exe } => {
renderer.render(UiLine::CommandOutput(
crate::i18n::t(crate::i18n::Msg::UpgradeDone {
version: &version,
backup: &backup.display().to_string(),
}).into_owned(),
));
if let Ok(mut g) = ctx.update_hint.lock() {
*g = None;
}
*done = Some(exe);
ctx.agent.cmd_tx.send(AgentCommand::Shutdown).ok();
}
UpgradeEvent::Failed(msg) => {
if msg.contains(atomcode_core::self_update::ALREADY_LATEST) {
let friendly = msg.replace(
&format!("{}: ", atomcode_core::self_update::ALREADY_LATEST),
"",
);
let (current, latest) = parse_already_latest_versions(&friendly)
.unwrap_or(("?", "?"));
renderer.render(UiLine::CommandOutput(
crate::i18n::t(crate::i18n::Msg::UpgradeAlreadyLatest { current, latest }).into_owned(),
));
} else {
renderer.render(UiLine::Error(
crate::i18n::t(crate::i18n::Msg::UpgradeFailed { error: &msg }).into_owned(),
));
}
}
UpgradeEvent::RolledBack { exe, backup } => {
renderer.render(UiLine::CommandOutput(
crate::i18n::t(crate::i18n::Msg::UpgradeRolledBack {
exe: &exe.display().to_string(),
backup: &backup.display().to_string(),
}).into_owned(),
));
*done = Some(exe);
ctx.agent.cmd_tx.send(AgentCommand::Shutdown).ok();
}
}
renderer.flush();
}
fn handle_agent_event(
ev: AgentEvent,
state: &mut UiState,
think: &mut ThinkStripper,
renderer: &mut dyn Renderer,
pending_tools: &mut std::collections::HashMap<String, (String, String, bool)>,
ctx: &mut LoopCtx,
fixissue_pending: &mut Option<atomcode_core::atomgit::IssueRef>,
fixissue_buffer: &mut String,
reasoning_buffer: &mut String,
buf: &Buffer,
) {
match ev {
AgentEvent::TextDelta(text) => {
let visible = think.feed(&text);
if !visible.is_empty() {
if fixissue_pending.is_some() {
fixissue_buffer.push_str(&visible);
}
renderer.render(UiLine::AssistantText(visible));
renderer.flush();
}
}
AgentEvent::ReasoningDelta(text) => {
if state.show_reasoning {
reasoning_buffer.push_str(&text);
if reasoning_buffer.contains('\n') || reasoning_buffer.len() > 80 {
let output = std::mem::take(reasoning_buffer);
renderer.render(UiLine::ReasoningText(output));
renderer.flush();
}
}
}
AgentEvent::ToolCallStreaming { name, .. } => {
state.on_tool_call_streaming(&display_tool_name(&name));
}
AgentEvent::ToolCallStarted {
id,
name,
arguments,
} => {
let detail = format_tool_detail(&name, &arguments);
let display = display_tool_name(&name);
if state.call_id_to_batch.contains_key(&id) {
pending_tools
.entry(id)
.or_insert((display.clone(), detail, true));
state.on_tool_call_started(&display);
return;
}
renderer.render(UiLine::AssistantLineBreak);
renderer.render(UiLine::ToolCallInFlight {
id: id.clone(),
name: display.clone(),
detail: detail.clone(),
});
renderer.flush();
pending_tools.insert(id, (display.clone(), detail, true));
state.on_tool_call_started(&display);
}
AgentEvent::ToolOutputChunk { call_id: _, chunk } => {
if state.show_tool_output {
renderer.render(UiLine::CommandOutput(chunk));
renderer.flush();
}
}
AgentEvent::ToolCallResult {
call_id,
name,
output,
success,
..
} => {
if let Some(batch_id) = state.call_id_to_batch.get(&call_id).cloned() {
let child_glyph = "\u{2514}";
let arrow = "\u{2192}";
let suffix = if success {
let n = output.lines().count().max(1);
let unit = if n == 1 { "line" } else { "lines" };
format!(" {} {} {}", arrow, n, unit)
} else {
format!(" {} \u{2717}", arrow)
};
let prefix = pending_tools
.remove(&call_id)
.map(|(_, det, _)| format!(
"{}({})",
display_tool_name_short(&name),
det
))
.unwrap_or_else(|| display_tool_name_short(&name));
renderer.render(UiLine::ToolGroupChildUpdate {
batch_id,
call_id: call_id.clone(),
new_text: format!(" {} {}{}", child_glyph, prefix, suffix),
});
renderer.flush();
return;
}
renderer.render(UiLine::AssistantLineBreak);
renderer.render(UiLine::ToolCallCommit {
call_id: Some(call_id.clone()),
});
let (display_name, detail, call_rendered) = pending_tools
.remove(&call_id)
.unwrap_or_else(|| (display_tool_name(&name), String::new(), false));
let safe_name = if display_name.is_empty() {
"(invalid)".to_string()
} else {
display_name
};
let suppress_body_echo = name == "parallel_edit_files";
if !call_rendered && !suppress_body_echo {
renderer.render(UiLine::ToolCall {
name: safe_name.clone(),
detail: detail.clone(),
});
}
if !suppress_body_echo {
let summary = summarise(&output, success);
renderer.render(UiLine::ToolResult { success, summary });
}
let emits_diff = matches!(
name.as_str(),
"edit_file" | "write_file" | "create_file" | "search_replace"
);
if emits_diff {
let diff_entries: Vec<crate::render::DiffEntry> = output
.lines()
.take(120)
.filter_map(|line| {
if let Some(rest) = line.strip_prefix("+ ") {
Some(crate::render::DiffEntry {
added: true,
text: rest.to_string(),
})
} else if let Some(rest) = line.strip_prefix("- ") {
Some(crate::render::DiffEntry {
added: false,
text: rest.to_string(),
})
} else {
None
}
})
.collect();
if !diff_entries.is_empty() {
renderer.render(UiLine::DiffBlock(diff_entries));
}
}
if name == "bash" && !state.show_tool_output {
renderer.render(UiLine::CommandOutput(
" ○ Press Ctrl+O to show real-time output\n".to_string(),
));
}
renderer.flush();
let _ = name;
}
AgentEvent::ApprovalNeeded {
tool_name, call, messages, ..
} => {
if !messages.is_empty() {
apply_session_messages(&mut ctx.current_session, messages);
ctx.bg_manager
.set_foreground_session(ctx.current_session.clone());
}
let display = display_tool_name(&tool_name);
let detail = pending_tools
.get(&call.id)
.map(|(_, det, _)| det.clone())
.unwrap_or_else(|| format_tool_detail(&tool_name, &call.arguments));
if let Some(entry) = pending_tools.get_mut(&call.id) {
let (disp, det, rendered) = entry;
if *rendered {
renderer.render(UiLine::ToolCallCommit {
call_id: Some(call.id.clone()),
});
} else {
renderer.render(UiLine::ToolCall {
name: disp.clone(),
detail: det.clone(),
});
*rendered = true;
}
} else {
renderer.render(UiLine::ToolCall {
name: display.clone(),
detail: detail.clone(),
});
pending_tools.insert(call.id.clone(), (display.clone(), detail.clone(), true));
}
renderer.render(UiLine::ApprovalPrompt {
tool: display.clone(),
detail: detail.clone(),
});
renderer.flush();
atomcode_core::notify::notify(
&ctx.config.notifications,
atomcode_core::notify::NotificationEvent::ApprovalNeeded(
atomcode_core::notify::ApprovalNotification {
tool_name: &display_tool_name(&tool_name),
detail: Some(&format_tool_detail(&tool_name, &call.arguments)),
working_dir: Some(&ctx.working_dir),
},
),
);
state.on_approval_needed(&display);
redraw_idle_plain(buf, state, ctx, renderer);
}
AgentEvent::PhaseChange(AgentPhase::Thinking) => state.on_thinking(),
AgentEvent::PhaseChange(AgentPhase::CallingTool(name)) => {
state.on_tool_call_streaming(&display_tool_name(&name));
}
AgentEvent::PhaseChange(_) => {}
AgentEvent::TurnComplete {
duration,
total_tokens,
turn_count,
tool_call_count,
stop_reason,
messages,
} => {
atomcode_core::notify::notify(
&ctx.config.notifications,
atomcode_core::notify::NotificationEvent::TurnFinished(
atomcode_core::notify::TurnNotification {
duration,
turn_count,
tool_call_count,
total_tokens: Some(total_tokens),
stop_reason,
working_dir: Some(&ctx.working_dir),
},
),
);
renderer.render(UiLine::AssistantLineBreak);
pending_tools.clear();
let done = state.next_done_label();
let dur = crate::render::fmt_dur(duration);
let label = crate::i18n::t(crate::i18n::Msg::TurnSummary {
done,
turn_count,
tool_call_count,
duration: &dur,
total_tokens,
})
.into_owned();
renderer.render(UiLine::TurnSeparator { label });
renderer.flush();
state.on_turn_complete();
think.reset();
reasoning_buffer.clear();
persist_current_session(ctx, messages, renderer);
if monitor::is_codingplan_provider(&ctx.config.default_provider) {
let cooled = ctx
.usage_last_check_at
.map(|t| t.elapsed() >= usage_monitor::USAGE_COOLDOWN)
.unwrap_or(true);
if cooled {
ctx.usage_last_check_at = Some(std::time::Instant::now());
usage_monitor::spawn_check(
ctx.usage_slot.clone(),
ctx.wake_tx.clone(),
);
}
}
if let Some(issue_ref) = fixissue_pending.take() {
let body = std::mem::take(fixissue_buffer);
if body.trim().is_empty() {
renderer.render(UiLine::CommandOutput(format!(
" [fixissue] agent produced no text; skipping comment + label on issue #{}\n",
issue_ref.number
)));
} else {
match atomcode_core::atomgit::fixissue::post_completion(&issue_ref, &body) {
Ok(()) => renderer.render(UiLine::CommandOutput(format!(
" [fixissue] ✓ posted summary + applied 'fixed' label to issue #{}\n",
issue_ref.number
))),
Err(e) => renderer.render(UiLine::CommandOutput(format!(
" [fixissue] ✗ post-back failed (local fix still saved): {:#}\n",
e
))),
}
}
renderer.flush();
}
}
AgentEvent::TurnCancelled { messages } => {
atomcode_core::notify::notify(
&ctx.config.notifications,
atomcode_core::notify::NotificationEvent::TurnFinished(
atomcode_core::notify::TurnNotification {
duration: state.turn_elapsed().unwrap_or_default(),
turn_count: 0,
tool_call_count: pending_tools.len(),
total_tokens: None,
stop_reason: atomcode_core::agent::TurnStopReason::Cancelled,
working_dir: Some(&ctx.working_dir),
},
),
);
for (_id, (name, detail, call_rendered)) in pending_tools.drain() {
let safe_name = if name.is_empty() {
"(invalid)".into()
} else {
name
};
if !call_rendered {
renderer.render(UiLine::ToolCall {
name: safe_name,
detail,
});
}
renderer.render(UiLine::ToolResult {
success: false,
summary: "(cancelled)".into(),
});
}
renderer.render(UiLine::TurnCancelled);
renderer.flush();
state.on_turn_cancelled();
fixissue_pending.take();
fixissue_buffer.clear();
think.reset();
persist_current_session(ctx, messages, renderer);
}
AgentEvent::Error { error, messages } => {
renderer.render(UiLine::Error(error));
renderer.flush();
fixissue_pending.take();
fixissue_buffer.clear();
state.on_error();
think.reset();
persist_current_session(ctx, messages, renderer);
}
AgentEvent::Warning(w) => {
renderer.render(UiLine::Warning(w));
renderer.flush();
}
AgentEvent::VisionPreprocessSuccess { vl_key, char_count } => {
let msg = crate::i18n::t(crate::i18n::Msg::VisionPreprocessSuccess { char_count })
.into_owned();
renderer.render(UiLine::VisionPreprocessSuccess {
msg,
model: vl_key,
});
renderer.flush();
}
AgentEvent::RestorePendingImages { images, markers } => {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
for (img, marker) in images.into_iter().zip(markers.into_iter()) {
let mut hasher = DefaultHasher::new();
img.data.hash(&mut hasher);
let h = hasher.finish();
cache_write_image(&crate::platform::image_cache_dir(), &img, h);
state.pending_image_hashes.push(h);
state.pending_images.push(img);
state.pending_image_markers.push(marker);
}
}
AgentEvent::TokenUsage(u) => {
state.prompt_tokens += u.prompt_tokens;
state.completion_tokens += u.completion_tokens;
state.cached_tokens += u.cached_tokens;
state.total_tokens += u.completion_tokens;
}
AgentEvent::WorkingDirChanged(new_dir) => {
if ctx.working_dir != new_dir {
ctx.previous_dir = Some(std::mem::replace(&mut ctx.working_dir, new_dir.clone()));
ctx.runtime_factory.set_working_dir(new_dir.clone());
commands::push_recent_dir(&mut ctx.recent_dirs, new_dir);
}
}
AgentEvent::ContextStats {
system_tokens,
sent_tokens,
dropped_tokens: _,
working_set_tokens: _,
total_messages,
tool_defs_tokens,
cold_zone_tokens,
ctx_window,
ctx_name,
system_prompt,
} => {
state.on_context_stats(
system_tokens,
sent_tokens,
tool_defs_tokens,
cold_zone_tokens,
total_messages,
ctx_window,
&ctx_name,
&system_prompt,
);
if ctx_window > 0 {
if let Some(show_prompt) = state.pending_context_render.take() {
renderer.render(UiLine::CommandOutput(commands::render_context_report(
state,
ctx,
show_prompt,
)));
renderer.flush();
}
}
}
AgentEvent::ToolBatchStarted { batch_id, calls } => {
let count = calls.len();
let unique_names: std::collections::HashSet<&str> =
calls.iter().map(|c| c.name.as_str()).collect();
let label = if unique_names.len() == 1 {
let single = unique_names.iter().next().copied().unwrap_or("tool");
format!("Running {} {} calls in parallel", count, single)
} else {
format!("Running {} tools in parallel", count)
};
let head_glyph = "\u{25cf}";
let child_glyph = "\u{2514}";
let header_text = format!("{} {}", head_glyph, label);
let raw_details: Vec<String> = calls
.iter()
.map(|c| format_tool_detail(&c.name, &c.arguments))
.collect();
let disambiguated = disambiguate_batch_details(
&calls.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
&calls.iter().map(|c| c.arguments.as_str()).collect::<Vec<_>>(),
&raw_details,
);
let children: Vec<crate::render::ToolGroupChild> = calls
.iter()
.zip(disambiguated.iter())
.map(|(c, detail)| crate::render::ToolGroupChild {
call_id: c.id.clone(),
text: format!(
" {} {}({})",
child_glyph,
display_tool_name_short(&c.name),
detail
),
})
.collect();
renderer.render(UiLine::AssistantLineBreak);
renderer.render(UiLine::ToolGroupRender {
batch_id: batch_id.clone(),
header: header_text,
children,
});
renderer.flush();
let call_ids: Vec<String> = calls.iter().map(|c| c.id.clone()).collect();
for cid in &call_ids {
state
.call_id_to_batch
.insert(cid.clone(), batch_id.clone());
}
for (c, detail) in calls.iter().zip(disambiguated.iter()) {
pending_tools.insert(
c.id.clone(),
(display_tool_name_short(&c.name), detail.clone(), true),
);
}
state.active_tool_batches.insert(
batch_id.clone(),
crate::state::ActiveToolBatch { call_ids },
);
}
AgentEvent::ToolBatchCompleted {
batch_id,
ok: _,
total: _,
elapsed_ms: _,
} => {
if let Some(b) = state.active_tool_batches.remove(&batch_id) {
for cid in b.call_ids {
state.call_id_to_batch.remove(&cid);
}
}
}
AgentEvent::SubAgentDispatchStart { tasks } => {
renderer.render(UiLine::CommandOutput(format!(
"Dispatching {} sub-agents in parallel...",
tasks.len()
)));
renderer.flush();
state.on_sub_agent_dispatch_start(tasks);
}
AgentEvent::SubAgentTaskStarted { index: _ } => {
}
AgentEvent::SubAgentTaskDone { index: _, elapsed_ms: _, turns: _, summary: _ } => {
state.on_sub_agent_task_done();
}
AgentEvent::SubAgentTaskFailed { index, elapsed_ms, turns: _, reason } => {
state.on_sub_agent_task_failed();
if let Some(info) = state.sub_agent_tasks.get(index) {
let cross = "\u{2717}";
let short_reason = reason.lines().next().unwrap_or("").trim();
renderer.render(UiLine::CommandOutput(format!(
" {} {}{} — {} · {}",
cross,
info.path,
info.dedup_suffix,
fmt_elapsed(elapsed_ms),
if short_reason.is_empty() { "failed" } else { short_reason }
)));
renderer.flush();
}
}
AgentEvent::SubAgentDispatchEnd => {
let total = state.sub_agent_total;
let failed = state.sub_agent_failed;
let ok = total.saturating_sub(failed);
let elapsed = state
.sub_agent_started_at
.map(|t| t.elapsed().as_millis() as u64)
.unwrap_or(0);
if total > 0 {
let arrow = "\u{25cf}";
let summary = if failed == 0 {
format!(
"{} ParallelEditFiles · {}/{} ok · {} wall",
arrow,
ok,
total,
fmt_elapsed(elapsed)
)
} else {
format!(
"{} ParallelEditFiles · {} ok · {} fail · {} wall",
arrow,
ok,
failed,
fmt_elapsed(elapsed)
)
};
renderer.render(UiLine::ToolGroupSummary { text: summary });
renderer.flush();
}
state.on_sub_agent_dispatch_end();
}
AgentEvent::BackgroundComplete { summary, files_edited, turns, success } => {
let header = if success {
crate::i18n::t(crate::i18n::Msg::BackgroundComplete { turns }).into_owned()
} else {
crate::i18n::t(crate::i18n::Msg::BackgroundFailed { turns }).into_owned()
};
let mut body = String::from(&header);
body.push_str(" ");
body.push_str(&summary);
if !body.ends_with('\n') {
body.push('\n');
}
if !files_edited.is_empty() {
body.push_str(&crate::i18n::t(crate::i18n::Msg::BackgroundFilesEdited));
for f in &files_edited {
body.push_str(&format!(" - {}\n", f));
}
}
if success {
renderer.render(UiLine::CommandOutput(body));
} else {
renderer.render(UiLine::Error(body));
}
renderer.flush();
}
AgentEvent::MessagesSync { messages } => {
if !messages.is_empty() {
apply_session_messages(&mut ctx.current_session, messages);
ctx.bg_manager
.set_foreground_session(ctx.current_session.clone());
}
}
}
}
fn persist_current_session(
ctx: &mut LoopCtx,
messages: Vec<atomcode_core::conversation::message::Message>,
renderer: &mut dyn Renderer,
) {
if messages.is_empty() {
return;
}
apply_session_messages(&mut ctx.current_session, messages);
ctx.bg_manager
.set_foreground_session(ctx.current_session.clone());
if let Err(e) = ctx.session_manager.save(&ctx.current_session) {
renderer.render(UiLine::Error(
crate::i18n::t(crate::i18n::Msg::SessionSaveFailed { error: &e.to_string() })
.into_owned(),
));
renderer.flush();
}
}
pub(crate) fn apply_session_messages(
session: &mut atomcode_core::session::Session,
messages: Vec<atomcode_core::conversation::message::Message>,
) {
if messages.is_empty() {
return;
}
session.messages = messages;
session.touch();
let should_rename = session.name == "default"
|| session.name.starts_with("session-")
|| session.name.trim_start().starts_with('[');
if should_rename {
use atomcode_core::conversation::message::Role;
let first_real_user = session
.messages
.iter()
.filter(|m| matches!(m.role, Role::User))
.find_map(|m| m.text().filter(|t| !is_synthetic_user_text(t)));
if let Some(text) = first_real_user {
let name: String = text.lines().next().unwrap_or("").chars().take(40).collect();
if !name.is_empty() {
session.name = name;
}
}
}
}
fn is_synthetic_user_text(text: &str) -> bool {
text.trim_start().starts_with('[')
}
#[cfg(test)]
mod session_naming_tests {
use super::{apply_session_messages, is_synthetic_user_text};
#[test]
fn apply_session_messages_renames_from_first_real_user() {
use atomcode_core::conversation::message::{Message, Role};
let mut session = atomcode_core::session::Session::default_session(
std::path::PathBuf::from("/tmp/project"),
);
let messages = vec![
Message::new(Role::User, "[System meta · not a user message]\nread this"),
Message::new(Role::User, "implement background sessions\nwith tests"),
];
apply_session_messages(&mut session, messages);
assert_eq!(session.name, "implement background sessions");
assert_eq!(session.messages.len(), 2);
}
#[test]
fn apply_session_messages_preserves_custom_name() {
use atomcode_core::conversation::message::{Message, Role};
let mut session = atomcode_core::session::Session::default_session(
std::path::PathBuf::from("/tmp/project"),
);
session.name = "manual name".to_string();
apply_session_messages(&mut session, vec![Message::new(Role::User, "new task")]);
assert_eq!(session.name, "manual name");
}
#[test]
fn synthetic_system_meta_is_detected() {
assert!(is_synthetic_user_text(
"[System meta · not a user message]\n12 calls..."
));
}
#[test]
fn synthetic_stuck_warning_is_detected() {
assert!(is_synthetic_user_text(
"[You are stuck — read foo.rs repeatedly without making progress.]"
));
}
#[test]
fn leading_whitespace_does_not_hide_synthetic_marker() {
assert!(is_synthetic_user_text(" [System meta] body"));
}
#[test]
fn real_user_message_is_not_synthetic() {
assert!(!is_synthetic_user_text("Fix the auth bug in login.rs"));
assert!(!is_synthetic_user_text("Continue."));
assert!(!is_synthetic_user_text("(why does this break?)"));
}
}
const CLIPBOARD_HINT_TTL_MS: u64 = 1500;
fn clipboard_image_hash(cache: &std::sync::Mutex<ClipboardCheckState>) -> Option<u64> {
let mut state = cache.lock().unwrap_or_else(|e| e.into_inner());
let stale = state
.last_checked
.map(|t| t.elapsed() >= std::time::Duration::from_millis(CLIPBOARD_HINT_TTL_MS))
.unwrap_or(true);
if stale {
state.image_hash = arboard::Clipboard::new()
.and_then(|mut c| c.get_image())
.ok()
.map(|img| rgba_fingerprint(img.width, img.height, img.bytes.as_ref()));
state.last_checked = Some(std::time::Instant::now());
}
state.image_hash
}
pub(crate) fn build_status(state: &UiState, ctx: &LoopCtx) -> crate::render::StatusLine {
let cwd = crate::platform::collapse_home(&ctx.working_dir.to_string_lossy());
let no_provider =
ctx.config.providers.is_empty() && atomcode_core::auth::get_stored_auth().is_none();
let hint: Option<(String, crate::render::HintSeverity)> = if no_provider {
Some((
crate::i18n::t(crate::i18n::Msg::StatusNoProvider).into_owned(),
crate::render::HintSeverity::Warning,
))
} else if let Some(warning) = ctx.monitor_warning.lock().ok().and_then(|g| g.clone()) {
Some((warning.display_text(), crate::render::HintSeverity::Warning))
} else if let Some(usage) =
usage_monitor::build_usage_hint(&ctx.usage_slot, &ctx.config.default_provider)
{
Some(usage)
} else if let Some(h) = clipboard_image_hash(&ctx.clipboard_check)
.filter(|h| !state.pending_image_hashes.contains(h))
{
let _ = h;
let hint_msg = if cfg!(target_os = "windows") {
crate::i18n::Msg::StatusClipboardImageHintSlash
} else {
crate::i18n::Msg::StatusClipboardImageHint
};
Some((
crate::i18n::t(hint_msg).into_owned(),
crate::render::HintSeverity::Info,
))
} else {
ctx.update_hint
.lock()
.ok()
.and_then(|g| g.clone())
.map(|v| {
(
crate::i18n::t(crate::i18n::Msg::StatusUpgradeHint { version: &v }).into_owned(),
crate::render::HintSeverity::Info,
)
})
};
let model = if no_provider {
crate::i18n::t(crate::i18n::Msg::StatusModelNotConfigured).into_owned()
} else {
ctx.model_name.clone()
};
let mode_indicator = match state.agent_mode {
crate::state::AgentMode::Plan => Some("PLAN".to_string()),
crate::state::AgentMode::Build => None,
};
let (ctx_used, ctx_window) = match state.last_context.as_ref() {
Some(snap) => (snap.sent_tokens, snap.ctx_window),
None => (0, 0),
};
let session_name = if ctx.current_session.user_renamed {
let name = ctx.current_session.name.trim();
if name.is_empty() {
None
} else {
Some(name.to_string())
}
} else {
None
};
crate::render::StatusLine {
model,
cwd,
ctx_used,
ctx_window,
hint,
mode_indicator,
session_name,
}
}
fn draw_spinner_now(
state: &mut UiState,
buf: &Buffer,
ctx: &LoopCtx,
renderer: &mut dyn Renderer,
queue_len: usize,
menu_selected: usize,
) {
let frame = state.tick_spinner();
let label = format_spinner_label(state, queue_len);
let status = build_status(state, ctx);
let menu = build_menu_items(&buf.text, buf.cursor, &ctx.commands, &ctx.custom_commands, Some(&ctx.skill_registry), Some(&ctx.file_index)).map(|items| {
let selected = menu_selected.min(items.len().saturating_sub(1));
let kind = if file_index::detect_at_mention_range(&buf.text, buf.cursor).is_some() {
crate::render::MenuKind::AtMention
} else {
crate::render::MenuKind::SlashCommand
};
crate::render::MenuPayload { items, selected, kind }
});
let attachments = compute_input_attachments(state, &buf.text);
renderer.render(UiLine::StreamingBox {
buf: buf.text.clone(),
cursor_byte: buf.cursor,
frame,
label,
status,
menu,
attachments,
});
renderer.flush();
}
fn format_spinner_label(state: &UiState, queue_len: usize) -> String {
let base = &state.spinner_label;
let mut out = format!("{}{}", base, state.ellipsis());
if let Some(d) = state.phase_elapsed() {
out.push_str(&format!(" · {}", crate::render::fmt_dur(d)));
}
if queue_len > 0 {
out.push_str(&format!(" · {} queued", queue_len));
}
out
}
pub fn display_tool_name(snake: &str) -> String {
let mut out = String::with_capacity(snake.len());
for word in snake.split('_') {
let mut chars = word.chars();
if let Some(c) = chars.next() {
out.extend(c.to_uppercase());
out.push_str(chars.as_str());
}
}
out
}
pub fn display_tool_name_short(snake: &str) -> String {
const STRIP_SUFFIXES: &[&str] = &["_files", "_file", "_directory"];
let trimmed = STRIP_SUFFIXES
.iter()
.find_map(|s| snake.strip_suffix(s))
.unwrap_or(snake);
display_tool_name(trimmed)
}
pub(crate) fn format_tool_detail(name: &str, args_json: &str) -> String {
let Ok(v) = serde_json::from_str::<serde_json::Value>(args_json) else {
return String::new();
};
let get_str = |k: &str| v.get(k).and_then(|x| x.as_str()).map(str::to_string);
let basename = |p: &str| p.rsplit('/').next().unwrap_or(p).to_string();
match name {
"read_file" | "edit_file" | "write_file" | "create_file" | "list_symbols" => {
get_str("file_path")
.map(|p| basename(&p))
.unwrap_or_default()
}
"read_symbol" => {
let sym = get_str("symbol").unwrap_or_default();
let file = get_str("file_path")
.map(|p| basename(&p))
.unwrap_or_default();
if sym.is_empty() {
file
} else if file.is_empty() {
sym
} else {
format!("{} in {}", sym, file)
}
}
"glob" => get_str("pattern")
.map(|p| crate::width::truncate_with_ellipsis(&p, 100))
.unwrap_or_default(),
"grep" => get_str("pattern")
.map(|p| crate::width::truncate_with_ellipsis(&p, 100))
.unwrap_or_default(),
"bash" => get_str("command")
.map(|c| crate::width::truncate_with_ellipsis(&c, 500))
.unwrap_or_default(),
"list_directory" | "change_dir" => get_str("path").unwrap_or_else(|| ".".into()),
"web_fetch" => get_str("url")
.map(|u| crate::width::truncate_with_ellipsis(&u, 150))
.unwrap_or_default(),
"web_search" => get_str("query")
.map(|q| crate::width::truncate_with_ellipsis(&q, 100))
.unwrap_or_default(),
"find_references" | "trace_callees" | "trace_callers" | "trace_chain" => {
get_str("symbol").unwrap_or_default()
}
"blast_radius" | "file_dependencies" => {
get_str("file").map(|p| basename(&p)).unwrap_or_default()
}
"search_replace" => {
let search = get_str("search");
let replace = get_str("replace");
let glob = get_str("glob");
let path = get_str("path");
match (&search, &replace) {
(Some(s), Some(r)) => {
let arrow = format!(
"{} → {}",
crate::width::truncate_with_ellipsis(s, 60),
crate::width::truncate_with_ellipsis(r, 60)
);
let mut parts = vec![arrow];
if let Some(g) = &glob {
parts.push(format!("glob: {}", g));
}
if let Some(p) = &path {
if p != "." {
parts.push(format!("path: {}", basename(p)));
}
}
parts.join(", ")
}
(None, Some(r)) => crate::width::truncate_with_ellipsis(r, 100),
(Some(s), None) => crate::width::truncate_with_ellipsis(s, 100),
_ => String::new(),
}
}
"use_skill" => get_str("name").unwrap_or_default(),
_ => {
for key in [
"file_path",
"path",
"file",
"pattern",
"query",
"url",
"name",
"symbol",
"command",
] {
if let Some(s) = get_str(key) {
return crate::width::truncate_with_ellipsis(&s, 100);
}
}
String::new()
}
}
}
fn disambiguate_batch_details(
names: &[&str],
args_jsons: &[&str],
raw_details: &[String],
) -> Vec<String> {
let mut seen = std::collections::HashMap::<&str, usize>::new();
let mut has_dups = false;
for d in raw_details {
let count = seen.entry(d.as_str()).or_insert(0);
*count += 1;
if *count > 1 {
has_dups = true;
}
}
if !has_dups {
return raw_details.to_vec();
}
let extract_path = |name: &str, args_json: &str| -> Option<String> {
let Ok(v) = serde_json::from_str::<serde_json::Value>(args_json) else {
return None;
};
let get_str = |k: &str| v.get(k).and_then(|x| x.as_str()).map(str::to_string);
match name {
"read_file" | "edit_file" | "write_file" | "create_file" | "list_symbols"
| "blast_radius" | "file_dependencies" => get_str("file_path").or_else(|| get_str("file")),
"search_replace" => get_str("file_path").or_else(|| get_str("file")),
"read_symbol" => get_str("file_path"),
_ => None,
}
};
let full_paths: Vec<Option<String>> = names
.iter()
.zip(args_jsons.iter())
.map(|(n, a)| extract_path(n, a))
.collect();
let mut result = raw_details.to_vec();
let mut groups: std::collections::HashMap<&str, Vec<usize>> =
std::collections::HashMap::new();
for (i, d) in raw_details.iter().enumerate() {
groups.entry(d.as_str()).or_default().push(i);
}
for (_detail, indices) in groups {
if indices.len() < 2 {
continue; }
let all_have_paths = indices.iter().all(|&i| full_paths[i].is_some());
if all_have_paths {
let paths: Vec<&str> = indices.iter().map(|&i| full_paths[i].as_deref().unwrap()).collect();
let mut depth = 1usize;
let max_depth = paths.iter().map(|p| p.matches('/').count()).max().unwrap_or(0);
loop {
let candidates: Vec<String> = paths
.iter()
.map(|p| tail_path(p, depth))
.collect();
let all_unique = {
let mut s = std::collections::HashSet::new();
candidates.iter().all(|c| s.insert(c.as_str()))
};
if all_unique || depth >= max_depth {
for (i, &idx) in indices.iter().enumerate() {
result[idx] = crate::width::truncate_with_ellipsis(
&candidates[i],
100,
);
}
break;
}
depth += 1;
}
} else {
for (seq, &idx) in indices.iter().enumerate() {
if seq > 0 {
let suffixed = format!("{} #{}", raw_details[idx], seq + 1);
result[idx] = crate::width::truncate_with_ellipsis(&suffixed, 100);
}
}
}
}
result
}
fn tail_path(path: &str, depth: usize) -> String {
if depth == 0 {
return path.rsplit('/').next().unwrap_or(path).to_string();
}
let mut seen = 0;
for (i, ch) in path.char_indices().rev() {
if ch == '/' {
seen += 1;
if seen == depth + 1 {
return path[(i + ch.len_utf8())..].to_string();
}
}
}
path.to_string()
}
pub(crate) fn fmt_elapsed(ms: u64) -> String {
let total_secs = ms / 1000;
if total_secs >= 60 {
format!("{}m{}s", total_secs / 60, total_secs % 60)
} else {
format!("{}s", total_secs)
}
}
pub(crate) fn summarise(output: &str, success: bool) -> String {
let first = output.lines().next().unwrap_or("(no output)");
let n = output.lines().count();
let budget = if success { 80 } else { 200 };
let trimmed = crate::width::truncate_with_ellipsis(first, budget);
if n > 1 {
format!("{} ({} lines)", trimmed, n)
} else {
trimmed
}
}