1use crate::app::message::Message;
8use crate::app::{App, AppConfig};
9use crate::attachment::{AttachmentOutcome, PromptAttachment, build_attachments_with};
10use crate::command::{AgentCommand, Command, CommandResult, FilesystemCommand, GitReviewCommand, PromptRejection};
11use crate::file_index::{FileEntry, MAX_INDEXED_FILES, file_entries};
12use crate::git_review::{
13 ClientState, ConnectionState, DiffDocument, DiffReviewEvent, DiffScope, DiffSnapshot, FileDiff, FileStatus,
14 LIVE_PROTOCOL_VERSION, RemoteError, RemoteErrorCode, RepositoryAction, ServerEvent, StageState,
15};
16pub use crate::renderer::RenderStats;
17use crate::renderer::Renderer;
18use crate::request::RequestId;
19use crate::session::platform::BrowserOpener;
20use crate::session::terminal::inline_viewport_height;
21use crate::session::workspace_status::WorkspaceStatus;
22use crate::settings::UiSettings;
23use crate::surfaces::composer::ComposerLayout;
24use acp_utils::client::AcpEvent;
25use acp_utils::content::display_content_blocks;
26use acp_utils::notifications::{
27 AetherCapabilities, ContextClearedParams, SubAgentEvent, SubAgentProgressParams, WorkspaceStatusResponse,
28};
29use agent_client_protocol::schema::MaybeUndefined;
30use agent_client_protocol::schema::v2::{self as acp, SessionId, SessionUpdate, ToolCallUpdate};
31use clankerdiff_core::git_patch_from_texts;
32use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
33use ratatui::backend::{Backend, ClearType, TestBackend, WindowSize};
34use ratatui::buffer::{Buffer, Cell};
35use ratatui::layout::{Position, Rect, Size};
36use ratatui::{Terminal, TerminalOptions, Viewport};
37use serde_json::json;
38use std::collections::{BTreeMap, BTreeSet, VecDeque};
39use std::fmt::Write as _;
40use std::path::{Path, PathBuf};
41use std::sync::{Arc, Mutex};
42use std::time::{Duration, Instant};
43use tokio::sync::mpsc::UnboundedReceiver;
44
45pub struct FakeExecutor {
51 available: VecDeque<Command>,
53 pending: VecDeque<Command>,
55 git: FakeGit,
56 published: Option<Arc<ClientState>>,
58 git_completion: Option<Result<(), String>>,
59 filesystem: FakeFilesystem,
60}
61
62impl Default for FakeExecutor {
63 fn default() -> Self {
64 Self::new()
65 }
66}
67
68impl FakeExecutor {
69 pub fn new() -> Self {
70 Self::with_git(FakeGit::default())
71 }
72
73 pub fn with_git(git: FakeGit) -> Self {
74 Self {
75 available: VecDeque::new(),
76 pending: VecDeque::new(),
77 git,
78 published: None,
79 git_completion: None,
80 filesystem: FakeFilesystem::default(),
81 }
82 }
83
84 pub fn git(&self) -> &FakeGit {
85 &self.git
86 }
87
88 pub fn git_review_active(&self) -> bool {
90 self.published.is_some()
91 }
92
93 pub fn git_mut(&mut self) -> &mut FakeGit {
94 &mut self.git
95 }
96
97 pub fn filesystem(&self) -> &FakeFilesystem {
98 &self.filesystem
99 }
100
101 pub fn filesystem_mut(&mut self) -> &mut FakeFilesystem {
102 &mut self.filesystem
103 }
104
105 pub fn next_git_review_state(&mut self) -> Option<Arc<ClientState>> {
106 self.sync_git_review()
107 }
108
109 pub fn record(&mut self, commands: impl IntoIterator<Item = Command>) {
110 for command in commands {
111 self.available.push_back(command.clone());
112 self.pending.push_back(command);
113 }
114 }
115
116 pub fn take_commands(&mut self) -> Vec<Command> {
117 self.pending.clear();
118 self.available.drain(..).collect()
119 }
120
121 fn complete(&mut self, command: Command) -> Option<CommandResult> {
122 match command {
123 Command::Agent(AgentCommand::FetchWorkspaceStatus { cwd, .. }) => {
124 let status = self.git.workspace_status();
125 Some(CommandResult::WorkspaceResolved { cwd: cwd.clone(), status: status.into() })
126 }
127 Command::GitReview(GitReviewCommand::Open { .. }) => {
128 self.git_completion = None;
129 self.published = Some(Arc::new(ClientState::default().apply(&ServerEvent::Initialize {
130 protocol_version: LIVE_PROTOCOL_VERSION,
131 repository_root: self.git.root().to_string_lossy().into_owned(),
132 })));
133 self.next_git_review_state().map(CommandResult::GitReview)
134 }
135 Command::GitReview(GitReviewCommand::Event(event)) => self.on_review_event(event),
136 Command::GitReview(GitReviewCommand::Close) => {
137 self.published = None;
138 self.git_completion = None;
139 None
140 }
141 Command::Filesystem(FilesystemCommand::PrepareSubmission { attachments }) => {
142 Some(CommandResult::SubmissionPrepared(self.filesystem.build_attachments(&attachments)))
143 }
144 Command::Filesystem(FilesystemCommand::IndexFiles { request_id, root }) => {
145 Some(CommandResult::FilesIndexed { request_id, files: self.filesystem.index_files(&root) })
146 }
147 _ => None,
148 }
149 }
150
151 fn on_review_event(&mut self, event: DiffReviewEvent) -> Option<CommandResult> {
152 match event {
153 DiffReviewEvent::SetScope(scope) => self.reload(scope),
154 DiffReviewEvent::Refresh => {
155 self.git_completion = Some(Ok(()));
156 self.next_git_review_state().map(CommandResult::GitReview)
157 }
158 DiffReviewEvent::RepositoryAction(action) => {
159 self.git_completion = Some(self.git.apply(action).map_err(|error| error.to_string()));
160 self.next_git_review_state().map(CommandResult::GitReview)
161 }
162 DiffReviewEvent::Cancel | DiffReviewEvent::SubmitReview(_) | DiffReviewEvent::CopyFormattedReview(_) => {
163 None
164 }
165 }
166 }
167
168 fn sync_git_review(&mut self) -> Option<Arc<ClientState>> {
169 let current = self.published.clone()?;
170 let scope = current.snapshot.as_ref().map_or(DiffScope::default(), |snapshot| snapshot.scope);
171 match self.git.load_diff(scope) {
172 Ok(snapshot) if current.snapshot.as_deref() != Some(&snapshot) => {
173 self.publish(¤t, &ServerEvent::Document(Arc::new(snapshot)))
174 }
175 Ok(_) if current.error.is_some() => self.publish(¤t, &ServerEvent::Health { error: None }),
176 Ok(_) => None,
177 Err(error) if current.error.as_ref() == Some(&error) => None,
178 Err(error) if current.snapshot.is_none() && !matches!(current.connection, ConnectionState::Failed(_)) => {
179 self.publish(¤t, &ServerEvent::Error(error))
180 }
181 Err(error) => self.publish(¤t, &ServerEvent::Health { error: Some(error) }),
182 }
183 }
184
185 fn publish(&mut self, current: &Arc<ClientState>, event: &ServerEvent) -> Option<Arc<ClientState>> {
186 let next = current.as_ref().clone().apply(event);
187 if &next == current.as_ref() {
188 return None;
189 }
190 let next = Arc::new(next);
191 self.published = Some(Arc::clone(&next));
192 Some(next)
193 }
194
195 fn reload(&mut self, scope: DiffScope) -> Option<CommandResult> {
196 let current = self.published.clone()?;
197 match self.git.load_diff(scope) {
198 Ok(snapshot) => {
199 self.git_completion = Some(Ok(()));
200 self.publish(¤t, &ServerEvent::Document(Arc::new(snapshot))).map(CommandResult::GitReview)
201 }
202 Err(error) => {
203 self.git_completion = Some(Err(error.to_string()));
204 None
205 }
206 }
207 }
208
209 fn take_pending(&mut self) -> Vec<Command> {
210 self.pending.drain(..).collect()
211 }
212
213 fn clear_available(&mut self) {
214 self.available.clear();
215 }
216}
217
218#[derive(Default)]
219struct FakeAgent {
220 running: bool,
221 queued: VecDeque<SentPrompt>,
222 inserted: usize,
223 rejection: Option<PromptRejection>,
224}
225
226struct SentPrompt {
227 request_id: RequestId,
228 content: Vec<acp::ContentBlock>,
229}
230
231impl FakeAgent {
232 fn observe(&mut self, message: &Message, session_id: &SessionId) {
233 let Message::Agent(event) = message else { return };
234 match event.as_ref() {
235 AcpEvent::SessionUpdate(notification) if notification.session_id == *session_id => {
236 match notification.update {
237 acp::SessionUpdate::StateUpdate(acp::StateUpdate::Running(_)) => self.running = true,
238 acp::SessionUpdate::StateUpdate(acp::StateUpdate::Idle(_)) => self.running = false,
239 _ => {}
240 }
241 }
242 AcpEvent::ConnectionClosed => {
243 self.running = false;
244 self.queued.clear();
245 }
246 _ => {}
247 }
248 }
249
250 fn receive(&mut self, commands: &[Command]) {
251 for command in commands {
252 if let Command::Agent(AgentCommand::Prompt { request_id, text, content, .. }) = command {
253 let mut blocks = vec![acp::ContentBlock::from(text.clone())];
254 blocks.extend(content.iter().flatten().cloned());
255 self.queued.push_back(SentPrompt { request_id: *request_id, content: blocks });
256 }
257 }
258 }
259}
260
261#[derive(Clone, Default)]
263pub struct FakeFilesystem {
264 files: BTreeMap<PathBuf, Vec<u8>>,
265 directories: BTreeSet<PathBuf>,
266 settings: Option<UiSettings>,
267}
268
269impl FakeFilesystem {
270 pub fn new() -> Self {
271 Self::default()
272 }
273
274 pub fn create_dir(&mut self, path: impl Into<PathBuf>) {
275 self.directories.insert(path.into());
276 }
277
278 pub fn write_file(&mut self, path: impl Into<PathBuf>, contents: impl AsRef<[u8]>) {
279 let path = path.into();
280 if let Some(parent) = path.parent() {
281 self.directories.insert(parent.to_path_buf());
282 }
283 self.files.insert(path, contents.as_ref().to_vec());
284 }
285
286 pub fn remove_file(&mut self, path: &Path) -> bool {
287 self.files.remove(path).is_some()
288 }
289
290 pub fn read_file(&self, path: &Path) -> Option<&[u8]> {
291 self.files.get(path).map(Vec::as_slice)
292 }
293
294 pub fn read_to_string(&self, path: &Path) -> Option<String> {
295 self.read_file(path).and_then(|contents| String::from_utf8(contents.to_vec()).ok())
296 }
297
298 pub fn contains(&self, path: &Path) -> bool {
299 self.files.contains_key(path) || self.directories.contains(path)
300 }
301
302 pub fn files(&self) -> impl Iterator<Item = (&Path, &[u8])> {
303 self.files.iter().map(|(path, contents)| (path.as_path(), contents.as_slice()))
304 }
305
306 pub fn directories(&self) -> impl Iterator<Item = &Path> {
307 self.directories.iter().map(PathBuf::as_path)
308 }
309
310 pub fn save_settings(&mut self, settings: UiSettings) {
311 self.settings = Some(settings);
312 }
313
314 pub fn settings(&self) -> Option<&UiSettings> {
315 self.settings.as_ref()
316 }
317
318 pub fn index_files(&self, root: &Path) -> Vec<FileEntry> {
321 let paths = self.files.keys().filter(|path| path.starts_with(root)).cloned();
322 file_entries(root, paths, MAX_INDEXED_FILES)
323 }
324
325 pub fn build_attachments(&self, attachments: &[PromptAttachment]) -> AttachmentOutcome {
328 build_attachments_with(attachments, |path, display_name| {
329 if self.directories.contains(path) {
330 return Err(format!("Failed to read {display_name}: is a directory"));
331 }
332 self.files.get(path).cloned().ok_or_else(|| format!("Failed to read {display_name}: file not found"))
333 })
334 }
335}
336
337#[derive(Clone, Default)]
340pub struct FakeGit {
341 state: std::sync::Arc<std::sync::Mutex<FakeGitState>>,
342}
343
344#[derive(Clone, Debug, PartialEq, Eq)]
345pub struct FakeGitFile {
346 pub path: String,
347 pub contents: Option<Vec<u8>>,
348 pub staged_contents: Option<Vec<u8>>,
349 pub committed_contents: Option<Vec<u8>>,
350}
351
352#[derive(Default)]
353struct FakeGitState {
354 root: PathBuf,
355 files: BTreeMap<String, FakeGitFile>,
356 commits: Vec<String>,
357 is_repo: bool,
358}
359
360impl FakeGit {
361 pub fn new(root: impl Into<PathBuf>) -> Self {
362 let state = FakeGitState { root: root.into(), is_repo: true, ..FakeGitState::default() };
363 Self { state: std::sync::Arc::new(std::sync::Mutex::new(state)) }
364 }
365
366 pub fn not_a_repository(root: impl Into<PathBuf>) -> Self {
367 let state = FakeGitState { root: root.into(), ..FakeGitState::default() };
368 Self { state: std::sync::Arc::new(std::sync::Mutex::new(state)) }
369 }
370
371 pub fn set_repository_available(&mut self, available: bool) {
372 self.state.lock().unwrap().is_repo = available;
373 }
374
375 pub fn root(&self) -> PathBuf {
376 self.state.lock().unwrap().root.clone()
377 }
378
379 pub fn add_file(&mut self, path: impl Into<String>, contents: impl AsRef<[u8]>) {
380 let path = path.into();
381 self.state.lock().unwrap().files.insert(
382 path.clone(),
383 FakeGitFile {
384 path,
385 contents: Some(contents.as_ref().to_vec()),
386 staged_contents: None,
387 committed_contents: None,
388 },
389 );
390 }
391
392 pub fn write_file(&mut self, path: impl Into<String>, contents: impl AsRef<[u8]>) {
393 let path = path.into();
394 let mut state = self.state.lock().unwrap();
395 let file = state.files.entry(path.clone()).or_insert_with(|| FakeGitFile {
396 path,
397 contents: None,
398 staged_contents: None,
399 committed_contents: None,
400 });
401 file.contents = Some(contents.as_ref().to_vec());
402 }
403
404 pub fn remove_file(&mut self, path: &str) {
405 if let Some(file) = self.state.lock().unwrap().files.get_mut(path) {
406 file.contents = None;
407 }
408 }
409
410 pub fn stage(&mut self, path: &str) -> bool {
411 let mut state = self.state.lock().unwrap();
412 let Some(file) = state.files.get_mut(path) else { return false };
413 file.staged_contents = file.contents.clone();
414 true
415 }
416
417 pub fn unstage(&mut self, path: &str) -> bool {
418 let mut state = self.state.lock().unwrap();
419 let Some(file) = state.files.get_mut(path) else { return false };
420 file.staged_contents = file.committed_contents.clone();
421 true
422 }
423
424 pub fn stage_all(&mut self) {
425 let mut state = self.state.lock().unwrap();
426 for file in state.files.values_mut() {
427 file.staged_contents = file.contents.clone();
428 }
429 }
430
431 pub fn unstage_all(&mut self) {
432 let mut state = self.state.lock().unwrap();
433 for file in state.files.values_mut() {
434 file.staged_contents = file.committed_contents.clone();
435 }
436 }
437
438 pub fn discard(&mut self, path: &str) -> bool {
439 let mut state = self.state.lock().unwrap();
440 let Some(file) = state.files.get_mut(path) else { return false };
441 file.contents = file.committed_contents.clone();
442 file.staged_contents = file.committed_contents.clone();
443 true
444 }
445
446 pub fn commit(&mut self, message: impl Into<String>) -> Result<(), RemoteError> {
447 let message = message.into();
448 let mut state = self.state.lock().unwrap();
449 if message.trim().is_empty() {
450 return Err(git_error("commit message must not be empty"));
451 }
452 if !state.files.values().any(|file| file.staged_contents != file.committed_contents) {
453 return Err(git_error("git operation `commit` failed with status Some(1)"));
454 }
455 for file in state.files.values_mut() {
456 if file.staged_contents != file.committed_contents {
457 file.committed_contents = file.staged_contents.clone();
458 }
459 }
460 state.commits.push(message);
461 Ok(())
462 }
463
464 pub fn file(&self, path: &str) -> Option<FakeGitFile> {
465 self.state.lock().unwrap().files.get(path).cloned()
466 }
467
468 pub fn files(&self) -> Vec<FakeGitFile> {
469 self.state.lock().unwrap().files.values().cloned().collect()
470 }
471
472 pub fn commits(&self) -> Vec<String> {
473 self.state.lock().unwrap().commits.clone()
474 }
475
476 pub fn status(&self, path: &str) -> Option<(FileStatus, StageState)> {
477 self.state.lock().unwrap().files.get(path).and_then(status_of)
478 }
479
480 pub fn apply(&mut self, action: RepositoryAction) -> Result<(), RemoteError> {
481 if !self.state.lock().unwrap().is_repo {
482 return Err(not_repository_error());
483 }
484 match action {
485 RepositoryAction::StagePaths(paths) => {
486 for path in paths {
487 self.stage(path.as_str());
488 }
489 Ok(())
490 }
491 RepositoryAction::UnstagePaths(paths) => {
492 for path in paths {
493 self.unstage(path.as_str());
494 }
495 Ok(())
496 }
497 RepositoryAction::StageAll => {
498 self.stage_all();
499 Ok(())
500 }
501 RepositoryAction::UnstageAll => {
502 self.unstage_all();
503 Ok(())
504 }
505 RepositoryAction::Commit { message } => self.commit(message),
506 RepositoryAction::Discard { path, status } => {
507 if status == FileStatus::Untracked {
508 self.state.lock().unwrap().files.remove(path.as_str());
509 } else {
510 self.discard(path.as_str());
511 }
512 Ok(())
513 }
514 }
515 }
516
517 pub fn workspace_status(&self) -> WorkspaceStatusResponse {
518 let state = self.state.lock().unwrap();
519 WorkspaceStatusResponse {
520 display_dir: state.root.to_string_lossy().into_owned(),
521 git_ref: Some("main".to_string()),
522 }
523 }
524
525 fn load_diff(&self, scope: DiffScope) -> Result<DiffSnapshot, RemoteError> {
526 let state = self.state.lock().unwrap();
527 if !state.is_repo {
528 return Err(not_repository_error());
529 }
530 let mut files = Vec::new();
531 for file in state.files.values() {
532 let (old, new) = match scope {
533 DiffScope::Staged => (&file.committed_contents, &file.staged_contents),
534 DiffScope::Unstaged => (&file.staged_contents, &file.contents),
535 DiffScope::Both => (&file.committed_contents, &file.contents),
536 };
537 if old == new {
538 continue;
539 }
540 let binary = old.as_ref().is_some_and(|bytes| is_binary(bytes))
541 || new.as_ref().is_some_and(|bytes| is_binary(bytes));
542 let mut diff = if binary {
543 FileDiff::from_texts(file.path.clone(), "", "").map_err(|error| git_error(error.to_string()))?
544 } else {
545 let old_text = old.as_deref().map(String::from_utf8_lossy).unwrap_or_default();
546 let new_text = new.as_deref().map(String::from_utf8_lossy).unwrap_or_default();
547 FileDiff::from_texts(file.path.clone(), &old_text, &new_text)
548 .map_err(|error| git_error(error.to_string()))?
549 };
550 diff.status = match (old, new) {
551 (None, Some(_)) if file.committed_contents.is_none() && file.staged_contents.is_none() => {
552 FileStatus::Untracked
553 }
554 (None, Some(_)) => FileStatus::Added,
555 (Some(_), None) => FileStatus::Deleted,
556 _ => FileStatus::Modified,
557 };
558 diff.old_path = old.is_some().then(|| diff.path.clone());
559 diff.staged = status_of(file).map_or(StageState::Unstaged, |(_, stage)| stage);
560 diff.binary = binary;
561 diff = diff.with_sources(fake_source(old.as_deref()), fake_source(new.as_deref()));
562 files.push(diff);
563 }
564 let document = DiffDocument { repo_root: state.root.to_string_lossy().into_owned(), files };
565 Ok(DiffSnapshot { scope, document: Arc::new(document) })
566 }
567}
568
569pub struct TestTerminal {
571 terminal: Terminal<TestBackend>,
572}
573
574impl TestTerminal {
575 pub fn new(width: u16, height: u16) -> Self {
576 Self { terminal: test_terminal(TestBackend::new(width, height)) }
577 }
578
579 pub fn terminal(&mut self) -> &mut Terminal<TestBackend> {
580 &mut self.terminal
581 }
582
583 pub fn resize(&mut self, width: u16, height: u16) {
584 self.terminal.backend_mut().resize(width, height);
585 }
586
587 pub fn viewport(&mut self) -> Buffer {
588 viewport_buffer(&mut self.terminal)
589 }
590
591 pub fn history(&mut self) -> Buffer {
592 history_buffer(&mut self.terminal)
593 }
594
595 pub fn conversation(&mut self) -> Buffer {
596 conversation_buffer(&mut self.terminal)
597 }
598}
599
600#[derive(Debug, Clone, Copy, PartialEq, Eq)]
604pub enum BackendEvent {
605 ShowCursor,
606 Scroll,
607}
608
609#[derive(Debug)]
610pub struct RecordingBackend {
611 inner: TestBackend,
612 events: Vec<BackendEvent>,
613}
614
615impl RecordingBackend {
616 pub fn new(width: u16, height: u16) -> Self {
617 Self { inner: TestBackend::new(width, height), events: Vec::new() }
618 }
619
620 pub fn events(&self) -> &[BackendEvent] {
621 &self.events
622 }
623
624 pub fn clear_events(&mut self) {
625 self.events.clear();
626 }
627
628 pub fn resize(&mut self, width: u16, height: u16) {
629 self.inner.resize(width, height);
630 }
631
632 pub fn buffer(&self) -> &Buffer {
633 self.inner.buffer()
634 }
635
636 pub fn scrollback(&self) -> &Buffer {
637 self.inner.scrollback()
638 }
639}
640
641impl Backend for RecordingBackend {
642 type Error = std::convert::Infallible;
643
644 fn draw<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
645 where
646 I: Iterator<Item = (u16, u16, &'a Cell)>,
647 {
648 self.inner.draw(content)
649 }
650
651 fn append_lines(&mut self, lines: u16) -> Result<(), Self::Error> {
652 self.inner.append_lines(lines)
653 }
654
655 fn hide_cursor(&mut self) -> Result<(), Self::Error> {
656 self.inner.hide_cursor()
657 }
658
659 fn show_cursor(&mut self) -> Result<(), Self::Error> {
660 self.events.push(BackendEvent::ShowCursor);
661 self.inner.show_cursor()
662 }
663
664 fn get_cursor_position(&mut self) -> Result<Position, Self::Error> {
665 self.inner.get_cursor_position()
666 }
667
668 fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> Result<(), Self::Error> {
669 self.inner.set_cursor_position(position)
670 }
671
672 fn clear(&mut self) -> Result<(), Self::Error> {
673 self.inner.clear()
674 }
675
676 fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error> {
677 self.inner.clear_region(clear_type)
678 }
679
680 fn size(&self) -> Result<Size, Self::Error> {
681 self.inner.size()
682 }
683
684 fn window_size(&mut self) -> Result<WindowSize, Self::Error> {
685 self.inner.window_size()
686 }
687
688 fn flush(&mut self) -> Result<(), Self::Error> {
689 self.inner.flush()
690 }
691
692 fn scroll_region_up(&mut self, region: std::ops::Range<u16>, lines: u16) -> Result<(), Self::Error> {
693 self.events.push(BackendEvent::Scroll);
694 let region = if region == (0..1) { 0..self.inner.size().unwrap().height } else { region };
695 self.inner.scroll_region_up(region, lines)
696 }
697
698 fn scroll_region_down(&mut self, region: std::ops::Range<u16>, lines: u16) -> Result<(), Self::Error> {
699 self.events.push(BackendEvent::Scroll);
700 self.inner.scroll_region_down(region, lines)
701 }
702}
703
704#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
709pub struct BackendStats {
710 pub draws: u64,
711 pub cells_drawn: u64,
712 pub scrolls: u64,
713}
714
715#[derive(Debug)]
718pub struct CountingBackend {
719 inner: TestBackend,
720 stats: BackendStats,
721}
722
723impl CountingBackend {
724 pub fn new(width: u16, height: u16) -> Self {
725 Self { inner: TestBackend::new(width, height), stats: BackendStats::default() }
726 }
727
728 pub fn take_stats(&mut self) -> BackendStats {
729 std::mem::take(&mut self.stats)
730 }
731
732 pub fn buffer(&self) -> &Buffer {
733 self.inner.buffer()
734 }
735
736 pub fn scrollback(&self) -> &Buffer {
737 self.inner.scrollback()
738 }
739}
740
741impl Backend for CountingBackend {
742 type Error = std::convert::Infallible;
743
744 fn draw<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
745 where
746 I: Iterator<Item = (u16, u16, &'a Cell)>,
747 {
748 self.stats.draws += 1;
749 let mut cells = 0u64;
750 let drawn = self.inner.draw(content.inspect(|_| cells += 1));
751 self.stats.cells_drawn += cells;
752 drawn
753 }
754
755 fn hide_cursor(&mut self) -> Result<(), Self::Error> {
756 self.inner.hide_cursor()
757 }
758
759 fn show_cursor(&mut self) -> Result<(), Self::Error> {
760 self.inner.show_cursor()
761 }
762
763 fn get_cursor_position(&mut self) -> Result<Position, Self::Error> {
764 self.inner.get_cursor_position()
765 }
766
767 fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> Result<(), Self::Error> {
768 self.inner.set_cursor_position(position)
769 }
770
771 fn clear(&mut self) -> Result<(), Self::Error> {
772 self.inner.clear()
773 }
774
775 fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error> {
776 self.inner.clear_region(clear_type)
777 }
778
779 fn size(&self) -> Result<Size, Self::Error> {
780 self.inner.size()
781 }
782
783 fn window_size(&mut self) -> Result<WindowSize, Self::Error> {
784 self.inner.window_size()
785 }
786
787 fn flush(&mut self) -> Result<(), Self::Error> {
788 self.inner.flush()
789 }
790
791 fn scroll_region_up(&mut self, region: std::ops::Range<u16>, lines: u16) -> Result<(), Self::Error> {
792 self.stats.scrolls += 1;
793 self.inner.scroll_region_up(region, lines)
794 }
795
796 fn scroll_region_down(&mut self, region: std::ops::Range<u16>, lines: u16) -> Result<(), Self::Error> {
797 self.stats.scrolls += 1;
798 self.inner.scroll_region_down(region, lines)
799 }
800}
801
802pub struct TestUi<B: Backend = TestBackend> {
810 app: App,
811 renderer: Renderer,
812 terminal: Terminal<B>,
813 executor: FakeExecutor,
814 agent: FakeAgent,
815 opened_urls: Arc<Mutex<Vec<String>>>,
816}
817
818impl<B: Backend> TestUi<B>
819where
820 B::Error: std::fmt::Debug,
821{
822 pub fn with_backend(backend: B) -> Self {
826 let builder = TestUiBuilder::new();
827 let app = App::new(builder.app_config());
828 Self {
829 app,
830 renderer: Renderer::new(),
831 terminal: test_terminal(backend),
832 executor: FakeExecutor::new(),
833 agent: FakeAgent::default(),
834 opened_urls: builder.opened_urls.clone(),
835 }
836 }
837
838 pub fn app(&self) -> &App {
842 &self.app
843 }
844
845 pub fn deliver(&mut self, message: Message) {
846 self.agent.observe(&message, self.app.session_id());
847 let commands = self.app.update(message);
848 self.agent.receive(&commands);
849 self.executor.record(commands);
850 if !self.agent.running {
851 self.insert_queued_prompts();
852 }
853 }
854
855 pub fn insert_queued_prompts(&mut self) {
856 while let Some(prompt) = self.agent.queued.pop_front() {
857 if let Some(rejection) = self.agent.rejection.clone() {
858 self.deliver_result(CommandResult::Prompt { request_id: prompt.request_id, result: Err(rejection) });
859 continue;
860 }
861 if !self.agent.running {
862 let session_id = self.app.session_id().clone();
863 let running =
864 acp::SessionUpdate::StateUpdate(acp::StateUpdate::Running(acp::RunningStateUpdate::new()));
865 self.acp_event(acp::UpdateSessionNotification::new(session_id, running).into());
866 }
867 self.agent.inserted += 1;
868 let message = acp::UserMessage::new(format!("user-message-{}", self.agent.inserted))
869 .content(display_content_blocks(&prompt.content));
870 self.acp_event(session_update(acp::SessionUpdate::UserMessage(message)));
871 self.deliver_result(CommandResult::Prompt { request_id: prompt.request_id, result: Ok(()) });
872 }
873 }
874
875 pub fn reject_queued_prompts(&mut self, rejection: &PromptRejection) {
876 while let Some(prompt) = self.agent.queued.pop_front() {
877 self.deliver_result(CommandResult::Prompt {
878 request_id: prompt.request_id,
879 result: Err(rejection.clone()),
880 });
881 }
882 }
883
884 pub fn reject_prompts(&mut self, rejection: PromptRejection) {
885 self.agent.rejection = Some(rejection);
886 }
887
888 pub fn deliver_result(&mut self, result: CommandResult) {
889 self.deliver(Message::CommandFinished(Box::new(result)));
890 }
891
892 pub fn executor(&self) -> &FakeExecutor {
894 &self.executor
895 }
896
897 pub fn executor_mut(&mut self) -> &mut FakeExecutor {
898 &mut self.executor
899 }
900
901 pub fn take_commands(&mut self) -> Vec<Command> {
902 self.executor.take_commands()
903 }
904
905 pub fn next_command(&mut self) -> Option<Command> {
906 self.executor.available.pop_front()
907 }
908
909 pub fn next_agent_command(&mut self) -> Option<AgentCommand> {
910 while let Some(command) = self.next_command() {
911 if let Command::Agent(command) = command {
912 return Some(command);
913 }
914 }
915 None
916 }
917
918 pub fn backend(&self) -> &B {
919 self.terminal.backend()
920 }
921
922 pub fn backend_mut(&mut self) -> &mut B {
923 self.terminal.backend_mut()
924 }
925
926 pub fn viewport_area(&mut self) -> ratatui::layout::Rect {
927 self.terminal.get_frame().area()
928 }
929
930 pub fn viewport_height(&mut self) -> u16 {
931 self.viewport_area().height
932 }
933
934 pub fn composer_layout(&mut self, width: u16) -> ComposerLayout {
936 let theme = self.app.theme().clone();
937 let composer = self.app.composer_mut();
938 composer.on_resize(width);
939 composer.layout(width, &theme)
940 }
941
942 pub fn draw(&mut self) {
944 self.try_draw().unwrap();
945 }
946
947 pub fn try_draw(&mut self) -> Result<(), crate::error::RenderError<B::Error>> {
948 self.renderer.draw(&mut self.terminal, &mut self.app)
949 }
950
951 pub fn render_stats(&mut self) -> RenderStats {
952 self.renderer.take_stats()
953 }
954
955 pub fn opened_urls(&self) -> Vec<String> {
958 self.opened_urls.lock().unwrap().clone()
959 }
960
961 pub fn seed_long_history(&mut self, turns: usize) {
966 for turn in 0..turns {
967 let prompt = format!("Turn {turn}: reconcile the writer path in module_{turn} and add a regression test.");
968 self.submit(&prompt);
969 self.acp_event(session_update(acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(
970 acp::ContentBlock::Text(acp::TextContent::new(format!(
971 "Reading module_{turn} to find the torn-update window before touching any call site."
972 ))),
973 format!("seed-thought-{turn}"),
974 ))));
975 self.acp_event(text_chunk_with_id(&format!("seed-response-{turn}"), SEED_PROSE));
976 self.acp_event(text_chunk_with_id(&format!("seed-response-{turn}"), SEED_CODE_BLOCK));
977 let bash = format!("seed-bash-{turn}");
978 self.acp_event(seed_bash_tool(&bash));
979 self.acp_event(tool_completed(&bash));
980 let edit = format!("seed-edit-{turn}");
981 self.acp_event(seed_edit_tool(&edit, turn));
982 self.acp_event(seed_tool_diff(&edit, turn));
983 if turn % 8 == 0 {
984 self.seed_sub_agent_tree(turn);
985 }
986 self.acp_event(text_chunk_with_id(&format!("seed-closing-{turn}"), SEED_CLOSING));
987 self.complete_prompt(acp::StopReason::EndTurn);
988 self.draw();
989 }
990 }
991
992 pub fn stream_message(&mut self, content: StreamContent, total_bytes: usize, chunk_bytes: usize) {
996 let thought = matches!(content, StreamContent::Thought);
997 let message = match content {
998 StreamContent::Prose => prose_message(total_bytes),
999 StreamContent::CodeBlock => code_block_message(total_bytes),
1000 StreamContent::Thought => thought_message(total_bytes),
1001 };
1002 for chunk in chunk_message(&message, chunk_bytes.max(1)) {
1003 if thought {
1004 self.acp_event(thought_chunk(&chunk));
1005 } else {
1006 self.acp_event(text_chunk(&chunk));
1007 }
1008 self.draw();
1009 }
1010 }
1011
1012 pub fn settle(&mut self) {
1015 self.complete_prompt(acp::StopReason::EndTurn);
1016 let mut now = Instant::now();
1017 for _ in 0..12 {
1020 self.tick(now);
1021 now += Duration::from_millis(500);
1022 if !self.app().wants_tick() {
1023 break;
1024 }
1025 }
1026 assert!(!self.app().wants_tick(), "a settled session must stop driving the tick loop");
1027 self.draw();
1028 }
1029
1030 pub fn terminal_event(&mut self, event: Event) {
1033 self.deliver(Message::Terminal(event));
1034 }
1035
1036 pub fn key(&mut self, key: KeyEvent) {
1037 self.deliver(Message::Terminal(Event::Key(key)));
1038 }
1039
1040 pub fn type_text(&mut self, text: &str) {
1041 for character in text.chars() {
1042 self.key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE));
1043 }
1044 }
1045
1046 pub fn submit(&mut self, text: &str) {
1048 self.type_text(text);
1049 self.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1050 }
1051
1052 pub fn paste(&mut self, text: &str) {
1053 self.deliver(Message::Terminal(Event::Paste(text.to_string())));
1054 }
1055
1056 pub fn acp_event(&mut self, event: AcpEvent) {
1057 self.deliver(Message::Agent(Box::new(event)));
1058 }
1059
1060 pub fn begin_resume(&mut self, session_id: &str, cwd: &str) {
1061 self.deliver_result(CommandResult::SessionsListed(Ok(acp::ListSessionsResponse::new(vec![
1062 acp::SessionInfo::new(session_id.to_string(), cwd),
1063 ]))));
1064 self.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1065 }
1066
1067 pub fn complete_prompt(&mut self, stop_reason: acp::StopReason) {
1069 let session_id = self.app.session_id().clone();
1070 let update = acp::SessionUpdate::StateUpdate(acp::StateUpdate::Idle(
1071 acp::IdleStateUpdate::new().stop_reason(stop_reason),
1072 ));
1073 self.acp_event(acp::UpdateSessionNotification::new(session_id, update).into());
1074 }
1075
1076 pub fn tick(&mut self, now: Instant) {
1077 self.deliver(Message::Tick(now));
1078 }
1079
1080 pub fn settle_tasks(&mut self) {
1083 self.executor.clear_available();
1084 let mut initial_batch = true;
1085 loop {
1086 let pending = self.executor.take_pending();
1087 if pending.is_empty() {
1088 if let Some(state) = self.executor.next_git_review_state() {
1089 self.deliver_result(CommandResult::GitReview(state));
1090 continue;
1091 }
1092 if let Some(result) = self.executor.git_completion.take() {
1093 self.deliver_result(CommandResult::GitReviewAction(result));
1094 continue;
1095 }
1096 return;
1097 }
1098 if initial_batch {
1099 for command in &pending {
1100 if matches!(command, Command::Agent(_)) {
1101 self.executor.available.push_back(command.clone());
1102 }
1103 }
1104 }
1105 for command in pending {
1106 if let Some(result) = self.executor.complete(command) {
1107 self.deliver(Message::CommandFinished(Box::new(result)));
1108 }
1109 }
1110 initial_batch = false;
1111 }
1112 }
1113
1114 fn seed_sub_agent_tree(&mut self, turn: usize) {
1115 let parent = format!("seed-spawn-{turn}");
1116 self.acp_event(seed_spawn_tool(&parent));
1117 self.acp_event(tool_completed(&parent));
1118 for agent in ["explorer", "fixer"] {
1119 let task = format!("{parent}-{agent}");
1120 let grep = format!("{task}-grep");
1121 self.acp_event(sub_agent_tool_update(
1122 &parent,
1123 &task,
1124 agent,
1125 ToolCallUpdate::new(grep.clone())
1126 .title("Grep")
1127 .name("coding__grep")
1128 .raw_input(json!({ "pattern": "torn update" })),
1129 ));
1130 self.acp_event(sub_agent_tool_update(
1131 &parent,
1132 &task,
1133 agent,
1134 ToolCallUpdate::new(grep).status(acp::ToolCallStatus::Completed),
1135 ));
1136 self.acp_event(sub_agent_progress(&parent, &task, agent, SubAgentEvent::Done));
1137 }
1138 }
1139}
1140
1141impl TestUi<TestBackend> {
1142 pub fn new() -> Self {
1144 Self::with_dimensions(40, 15)
1145 }
1146
1147 pub fn with_dimensions(width: u16, height: u16) -> Self {
1148 TestUiBuilder::new().dimensions(width, height).build()
1149 }
1150
1151 pub fn resize(&mut self, width: u16, height: u16) {
1154 self.terminal.backend_mut().resize(width, height);
1155 }
1156}
1157
1158impl Default for TestUi<TestBackend> {
1159 fn default() -> Self {
1160 Self::new()
1161 }
1162}
1163
1164pub trait BuffersReader {
1167 fn screen(&self) -> &Buffer;
1168 fn scrollback(&self) -> &Buffer;
1169}
1170
1171impl BuffersReader for TestBackend {
1172 fn screen(&self) -> &Buffer {
1173 self.buffer()
1174 }
1175
1176 fn scrollback(&self) -> &Buffer {
1177 self.scrollback()
1178 }
1179}
1180
1181impl BuffersReader for CountingBackend {
1182 fn screen(&self) -> &Buffer {
1183 self.buffer()
1184 }
1185
1186 fn scrollback(&self) -> &Buffer {
1187 self.scrollback()
1188 }
1189}
1190
1191impl BuffersReader for RecordingBackend {
1192 fn screen(&self) -> &Buffer {
1193 self.buffer()
1194 }
1195
1196 fn scrollback(&self) -> &Buffer {
1197 self.scrollback()
1198 }
1199}
1200
1201impl<B> TestUi<B>
1202where
1203 B: Backend + BuffersReader,
1204 B::Error: std::fmt::Debug,
1205{
1206 pub fn viewport(&mut self) -> Buffer {
1210 self.draw();
1211 viewport_buffer(&mut self.terminal)
1212 }
1213
1214 pub fn history(&mut self) -> Buffer {
1217 self.draw();
1218 history_buffer(&mut self.terminal)
1219 }
1220
1221 pub fn conversation(&mut self) -> Buffer {
1224 self.draw();
1225 conversation_buffer(&mut self.terminal)
1226 }
1227
1228 pub fn viewport_text(&mut self) -> String {
1229 buffer_text(&self.viewport())
1230 }
1231
1232 pub fn history_text(&mut self) -> String {
1233 buffer_text(&self.history())
1234 }
1235
1236 pub fn conversation_text(&mut self) -> String {
1237 buffer_text(&self.conversation())
1238 }
1239
1240 pub fn viewport_row(&mut self, needle: &str) -> Option<u16> {
1242 row_containing(&self.viewport(), needle)
1243 }
1244
1245 pub fn assert_viewport_contains(&mut self, needle: &str) {
1246 let viewport = self.viewport_text();
1247 assert!(
1248 viewport.contains(needle),
1249 "viewport should contain {needle:?}:
1250{viewport}"
1251 );
1252 }
1253
1254 pub fn assert_viewport_not_contains(&mut self, needle: &str) {
1255 let viewport = self.viewport_text();
1256 assert!(
1257 !viewport.contains(needle),
1258 "viewport should not contain {needle:?}:
1259{viewport}"
1260 );
1261 }
1262
1263 pub fn assert_history_contains(&mut self, needle: &str) {
1264 let history = self.history_text();
1265 assert!(
1266 history.contains(needle),
1267 "history should contain {needle:?}:
1268{history}"
1269 );
1270 }
1271
1272 pub fn assert_history_not_contains(&mut self, needle: &str) {
1273 let history = self.history_text();
1274 assert!(
1275 !history.contains(needle),
1276 "history should not contain {needle:?}:
1277{history}"
1278 );
1279 }
1280
1281 pub fn assert_conversation_contains(&mut self, needle: &str) {
1282 let conversation = self.conversation_text();
1283 assert!(
1284 conversation.contains(needle),
1285 "conversation should contain {needle:?}:
1286{conversation}"
1287 );
1288 }
1289
1290 pub fn assert_conversation_not_contains(&mut self, needle: &str) {
1291 let conversation = self.conversation_text();
1292 assert!(
1293 !conversation.contains(needle),
1294 "conversation should not contain {needle:?}:
1295{conversation}"
1296 );
1297 }
1298
1299 pub fn assert_viewport<S: AsRef<str>>(&mut self, expected: &[S]) {
1301 assert_buffer_eq(&self.viewport(), expected);
1302 }
1303
1304 pub fn assert_history<S: AsRef<str>>(&mut self, expected: &[S]) {
1306 assert_buffer_eq(&self.history(), expected);
1307 }
1308
1309 pub fn assert_conversation<S: AsRef<str>>(&mut self, expected: &[S]) {
1311 assert_buffer_eq(&self.conversation(), expected);
1312 }
1313}
1314
1315pub struct TestUiBuilder {
1318 width: u16,
1319 height: u16,
1320 working_dir: Option<PathBuf>,
1321 workspace_access: crate::session::WorkspaceAccess,
1322 capabilities: AetherCapabilities,
1323 prompt_capabilities: acp::PromptCapabilities,
1324 config_options: Vec<acp::SessionConfigOption>,
1325 auth_methods: Vec<acp::AuthMethod>,
1326 session_capabilities: Option<acp::SessionCapabilities>,
1327 settings: UiSettings,
1328 workspace_status: Option<WorkspaceStatus>,
1329 git: FakeGit,
1330 opened_urls: Arc<Mutex<Vec<String>>>,
1331}
1332
1333impl Default for TestUiBuilder {
1334 fn default() -> Self {
1335 Self {
1336 width: 40,
1337 height: 15,
1338 working_dir: None,
1339 workspace_access: crate::session::WorkspaceAccess::Local,
1340 capabilities: AetherCapabilities::default(),
1341 prompt_capabilities: acp::PromptCapabilities::new(),
1342 config_options: Vec::new(),
1343 auth_methods: Vec::new(),
1344 session_capabilities: None,
1345 settings: UiSettings::default(),
1346 workspace_status: None,
1347 git: FakeGit::default(),
1348 opened_urls: Arc::new(Mutex::new(Vec::new())),
1349 }
1350 }
1351}
1352
1353impl TestUiBuilder {
1354 pub fn new() -> Self {
1355 Self::default()
1356 }
1357
1358 pub fn dimensions(mut self, width: u16, height: u16) -> Self {
1359 self.width = width;
1360 self.height = height;
1361 self
1362 }
1363
1364 pub fn remote_workspace(mut self) -> Self {
1365 self.workspace_access = crate::session::WorkspaceAccess::Remote;
1366 self
1367 }
1368
1369 pub fn working_dir(mut self, working_dir: impl Into<PathBuf>) -> Self {
1370 self.working_dir = Some(working_dir.into());
1371 self
1372 }
1373
1374 pub fn prompt_capabilities(mut self, capabilities: acp::PromptCapabilities) -> Self {
1375 self.prompt_capabilities = capabilities;
1376 self
1377 }
1378
1379 pub fn config_options(mut self, options: Vec<acp::SessionConfigOption>) -> Self {
1380 self.config_options = options;
1381 self
1382 }
1383
1384 pub fn auth_methods(mut self, methods: Vec<acp::AuthMethod>) -> Self {
1385 self.auth_methods = methods;
1386 self
1387 }
1388
1389 pub fn settings(mut self, settings: UiSettings) -> Self {
1390 self.settings = settings;
1391 self
1392 }
1393
1394 pub fn workspace_status(mut self, workspace_status: WorkspaceStatus) -> Self {
1395 self.workspace_status = Some(workspace_status);
1396 self
1397 }
1398
1399 pub fn git(mut self, git: FakeGit) -> Self {
1400 self.git = git;
1401 self
1402 }
1403
1404 pub fn session_capabilities(mut self, capabilities: acp::SessionCapabilities) -> Self {
1407 self.session_capabilities = Some(capabilities);
1408 self
1409 }
1410
1411 pub fn prompt_search(mut self) -> Self {
1412 self.capabilities.prompt_search = true;
1413 self
1414 }
1415
1416 pub fn session_preview(mut self) -> Self {
1417 self.capabilities.session_preview = true;
1418 self
1419 }
1420
1421 pub fn workspace_move(mut self) -> Self {
1422 self.capabilities.workspace_move = true;
1423 self
1424 }
1425
1426 pub fn build(self) -> TestUi {
1428 self.finish()
1429 }
1430
1431 pub fn build_from_session(self, session: crate::session::Session) -> (TestUi, UnboundedReceiver<AcpEvent>) {
1432 let (app, events, _) = App::from_session(session, self.settings.clone());
1433 let mut ui = self.finish_with_app(app);
1434 ui.executor.record(ui.app.take_commands());
1435 (ui, events)
1436 }
1437
1438 fn finish(self) -> TestUi {
1439 let app = App::new(self.app_config());
1440 self.finish_with_app(app)
1441 }
1442
1443 fn finish_with_app(self, app: App) -> TestUi {
1444 TestUi {
1445 app,
1446 renderer: Renderer::new(),
1447 terminal: test_terminal(TestBackend::new(self.width, self.height)),
1448 executor: FakeExecutor::with_git(self.git),
1449 agent: FakeAgent::default(),
1450 opened_urls: self.opened_urls,
1451 }
1452 }
1453
1454 fn app_config(&self) -> AppConfig {
1455 let session_capabilities = self
1456 .session_capabilities
1457 .clone()
1458 .unwrap_or_else(|| acp::SessionCapabilities::new().meta(Some(self.capabilities.clone().to_meta())));
1459 AppConfig {
1460 initialize_response: acp::InitializeResponse::new(
1461 agent_client_protocol::schema::ProtocolVersion::V2,
1462 acp::Implementation::new("aether", "test"),
1463 )
1464 .capabilities(
1465 acp::AgentCapabilities::new().session(session_capabilities.prompt(self.prompt_capabilities.clone())),
1466 )
1467 .auth_methods(self.auth_methods.clone()),
1468 session_response: acp::NewSessionResponse::new("test-session").config_options(self.config_options.clone()),
1469 workspace_status: self
1470 .workspace_status
1471 .clone()
1472 .unwrap_or_else(|| WorkspaceStatus::new("~/code/demo", Some("main".to_string()))),
1473 working_dir: self.working_dir.clone().unwrap_or_else(|| PathBuf::from(".")),
1474 workspace_access: self.workspace_access,
1475 settings: self.settings.clone(),
1476 browser_opener: {
1477 let opened = self.opened_urls.clone();
1478 Arc::new(move |url: &str| {
1479 opened.lock().unwrap().push(url.to_string());
1480 Ok(())
1481 }) as BrowserOpener
1482 },
1483 clipboard_writer: Arc::new(|_| Ok(())),
1484 }
1485 }
1486}
1487
1488pub fn has_cell(buffer: &Buffer, symbol: &str, predicate: impl Fn(&Cell) -> bool) -> bool {
1490 for y in buffer.area.top()..buffer.area.bottom() {
1491 for x in buffer.area.left()..buffer.area.right() {
1492 if let Some(cell) = buffer.cell((x, y))
1493 && cell.symbol() == symbol
1494 && predicate(cell)
1495 {
1496 return true;
1497 }
1498 }
1499 }
1500 false
1501}
1502
1503pub fn line_text(line: &ratatui::text::Line<'_>) -> String {
1504 line.spans.iter().map(|span| span.content.as_ref()).collect()
1505}
1506
1507pub fn rows_with_background(buffer: &Buffer, background: ratatui::style::Color) -> usize {
1509 (buffer.area.top()..buffer.area.bottom())
1510 .filter(|&y| {
1511 (buffer.area.left()..buffer.area.right())
1512 .any(|x| buffer.cell((x, y)).is_some_and(|cell| cell.bg == background))
1513 })
1514 .count()
1515}
1516
1517pub fn row_containing(buffer: &Buffer, needle: &str) -> Option<u16> {
1518 (buffer.area.top()..buffer.area.bottom()).find(|&y| {
1519 let row = (buffer.area.left()..buffer.area.right())
1520 .map(|x| buffer.cell((x, y)).map_or(" ", Cell::symbol))
1521 .collect::<String>();
1522 row.contains(needle)
1523 })
1524}
1525
1526pub fn buffer_text(buffer: &Buffer) -> String {
1527 let mut out = String::new();
1528 for y in buffer.area.top()..buffer.area.bottom() {
1529 for x in buffer.area.left()..buffer.area.right() {
1530 out.push_str(buffer.cell((x, y)).map_or(" ", Cell::symbol));
1531 }
1532 out.push('\n');
1533 }
1534 out
1535}
1536
1537pub fn assert_buffer_eq<S: AsRef<str>>(buffer: &Buffer, expected: &[S]) {
1541 let actual_lines: Vec<String> =
1542 (buffer.area.top()..buffer.area.bottom()).map(|y| row_text(buffer, y).trim_end().to_string()).collect();
1543 for index in 0..actual_lines.len().max(expected.len()) {
1544 let actual_line = actual_lines.get(index).map_or("", String::as_str);
1545 let expected_line = expected.get(index).map_or("", AsRef::as_ref).trim_end();
1546 assert_eq!(
1547 actual_line,
1548 expected_line,
1549 "line {index} mismatch:\n expected: {expected_line:?}\n actual: {actual_line:?}\n\nfull buffer:\n{}",
1550 actual_lines.join("\n")
1551 );
1552 }
1553}
1554
1555pub fn row_text(buffer: &Buffer, y: u16) -> String {
1556 (buffer.area.left()..buffer.area.right()).map(|x| buffer.cell((x, y)).map_or(" ", Cell::symbol)).collect()
1557}
1558
1559#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1561pub enum StreamContent {
1562 Prose,
1565 CodeBlock,
1569 Thought,
1572}
1573
1574pub fn session_update(update: acp::SessionUpdate) -> AcpEvent {
1575 acp::UpdateSessionNotification::new(SessionId::new("test-session"), update).into()
1576}
1577
1578pub fn context_cleared() -> AcpEvent {
1579 AcpEvent::ContextCleared(ContextClearedParams { session_id: SessionId::new("test-session") })
1580}
1581
1582pub fn compaction_update(id: &str, status: acp::CompactionStatus) -> AcpEvent {
1583 session_update(acp::SessionUpdate::CompactionUpdate(acp::CompactionUpdate::new(id, status)))
1584}
1585
1586pub fn text_chunk(text: &str) -> AcpEvent {
1587 text_chunk_with_id("assistant", text)
1588}
1589
1590pub fn text_chunk_with_id(message_id: &str, text: &str) -> AcpEvent {
1591 session_update(acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
1592 acp::ContentBlock::Text(acp::TextContent::new(text)),
1593 message_id.to_string(),
1594 )))
1595}
1596
1597pub fn thought_chunk(text: &str) -> AcpEvent {
1598 thought_chunk_with_id("thought", text)
1599}
1600
1601pub fn thought_chunk_with_id(message_id: &str, text: &str) -> AcpEvent {
1602 session_update(acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(
1603 acp::ContentBlock::Text(acp::TextContent::new(text)),
1604 message_id.to_string(),
1605 )))
1606}
1607
1608pub fn text_diff(path: &str, old: &str, new: &str) -> acp::Diff {
1609 let diff_text = git_patch_from_texts(path, Some(old), Some(new)).expect("valid text diff");
1610 acp::Diff::new(vec![acp::DiffChange::modify(acp::AbsolutePath::new(path))])
1611 .with_patch(diff_text.map(acp::DiffPatch::new))
1612}
1613
1614pub fn sub_agent_progress(parent: &str, task: &str, agent: &str, event: SubAgentEvent) -> AcpEvent {
1615 AcpEvent::SubAgentProgress(SubAgentProgressParams {
1616 session_id: SessionId::new("test-session"),
1617 parent_tool_id: parent.to_string(),
1618 task_id: task.to_string(),
1619 agent_name: agent.to_string(),
1620 event,
1621 })
1622}
1623
1624pub fn sub_agent_tool_update(parent: &str, task: &str, agent: &str, update: ToolCallUpdate) -> AcpEvent {
1625 sub_agent_progress(parent, task, agent, SubAgentEvent::ToolCallUpdate(Box::new(update)))
1626}
1627
1628pub fn tool_completed(id: &str) -> AcpEvent {
1629 session_update(acp::SessionUpdate::ToolCallUpdate(
1630 acp::ToolCallUpdate::new(id.to_string()).status(acp::ToolCallStatus::Completed),
1631 ))
1632}
1633
1634pub fn chunk_message(message: &str, chunk_bytes: usize) -> Vec<String> {
1635 let mut chunks = Vec::new();
1636 let mut rest = message;
1637 while !rest.is_empty() {
1638 let mut end = rest.len().min(chunk_bytes);
1639 while !rest.is_char_boundary(end) {
1640 end -= 1;
1641 }
1642 chunks.push(rest[..end].to_string());
1643 rest = &rest[end..];
1644 }
1645 chunks
1646}
1647
1648fn git_error(message: impl Into<String>) -> RemoteError {
1649 RemoteError::new(RemoteErrorCode::Git, message)
1650}
1651
1652fn not_repository_error() -> RemoteError {
1653 git_error("path is not inside a Git worktree")
1654}
1655
1656fn status_of(file: &FakeGitFile) -> Option<(FileStatus, StageState)> {
1657 let staged_changed = file.staged_contents != file.committed_contents;
1658 let working_changed = file.contents != file.staged_contents;
1659 if !staged_changed && !working_changed {
1660 return None;
1661 }
1662
1663 if file.committed_contents.is_none() {
1664 let stage = match (file.staged_contents.is_some(), working_changed) {
1665 (true, true) => StageState::PartiallyStaged,
1666 (true, false) => StageState::Staged,
1667 (false, _) => StageState::Unstaged,
1668 };
1669 return Some((FileStatus::Untracked, stage));
1670 }
1671
1672 let stage = match (staged_changed, working_changed) {
1673 (true, true) => StageState::PartiallyStaged,
1674 (true, false) => StageState::Staged,
1675 (false, true) => StageState::Unstaged,
1676 (false, false) => unreachable!("clean files returned above"),
1677 };
1678 let status = if file.contents.is_none() { FileStatus::Deleted } else { FileStatus::Modified };
1679 Some((status, stage))
1680}
1681
1682fn fake_source(bytes: Option<&[u8]>) -> clankerdiff_ratatui::diff::SourceResult {
1683 use clankerdiff_ratatui::diff::{SourceDocument, SourceUnavailable};
1684 match bytes {
1685 None => Err(SourceUnavailable::Absent),
1686 Some(bytes) if is_binary(bytes) => Err(SourceUnavailable::Binary),
1687 Some(bytes) => SourceDocument::new(String::from_utf8_lossy(bytes)).map(std::sync::Arc::new),
1688 }
1689}
1690
1691fn is_binary(bytes: &[u8]) -> bool {
1692 bytes.iter().take(8192).any(|byte| *byte == 0) || std::str::from_utf8(bytes).is_err()
1693}
1694
1695fn test_terminal<B: Backend>(backend: B) -> Terminal<B>
1698where
1699 B::Error: std::fmt::Debug,
1700{
1701 let height = backend.size().unwrap().height;
1702 Terminal::with_options(backend, TerminalOptions { viewport: Viewport::Inline(inline_viewport_height(height)) })
1703 .unwrap()
1704}
1705
1706fn viewport_buffer<B>(terminal: &mut Terminal<B>) -> Buffer
1709where
1710 B: Backend + BuffersReader,
1711{
1712 let area = terminal.get_frame().area();
1713 let screen = terminal.backend().screen();
1714 let mut viewport = Buffer::empty(Rect::new(0, 0, area.width, area.height));
1715 for y in 0..area.height {
1716 for x in 0..area.width {
1717 viewport[(x, y)] = screen[(area.x + x, area.y + y)].clone();
1718 }
1719 }
1720 viewport
1721}
1722
1723fn history_buffer<B>(terminal: &mut Terminal<B>) -> Buffer
1725where
1726 B: Backend + BuffersReader,
1727{
1728 let viewport_area = terminal.get_frame().area();
1729 let screen = terminal.backend().screen();
1730 let scrollback = terminal.backend().scrollback();
1731 let history_height = scrollback.area.height.saturating_add(viewport_area.top());
1732 let mut history = Buffer::empty(Rect::new(0, 0, screen.area.width, history_height));
1733 for y in 0..scrollback.area.height {
1734 for x in 0..scrollback.area.width {
1735 history[(x, y)] = scrollback[(x, y)].clone();
1736 }
1737 }
1738 for y in 0..viewport_area.top() {
1739 for x in 0..screen.area.width {
1740 history[(x, scrollback.area.height + y)] = screen[(x, y)].clone();
1741 }
1742 }
1743 history
1744}
1745
1746fn conversation_buffer<B>(terminal: &mut Terminal<B>) -> Buffer
1747where
1748 B: Backend + BuffersReader,
1749{
1750 let history = history_buffer(terminal);
1751 let viewport = viewport_buffer(terminal);
1752 let mut conversation =
1753 Buffer::empty(Rect::new(0, 0, viewport.area.width, history.area.height.saturating_add(viewport.area.height)));
1754 for y in 0..history.area.height {
1755 for x in 0..history.area.width {
1756 conversation[(x, y)] = history[(x, y)].clone();
1757 }
1758 }
1759 for y in 0..viewport.area.height {
1760 for x in 0..viewport.area.width {
1761 conversation[(x, history.area.height + y)] = viewport[(x, y)].clone();
1762 }
1763 }
1764 conversation
1765}
1766
1767const SEED_PROSE: &str = "\
1768Examining the request. The module guards its invariants behind a shared handle,
1769so the fix has to land on the writer side rather than at each call site. I will
1770rework the boundary so retries cannot observe a torn update, then cover the
1771regression with a test that fails on the current code.
1772
1773";
1774
1775const SEED_CODE_BLOCK: &str = "\
1776```rust
1777fn reconcile(state: &mut State, incoming: Vec<Delta>) -> Outcome {
1778 let mut applied = Vec::with_capacity(incoming.len());
1779 for delta in incoming {
1780 if !state.accepts(&delta) {
1781 continue;
1782 }
1783 state.apply(&delta);
1784 applied.push(delta);
1785 }
1786 state.commit(applied)
1787}
1788```
1789
1790";
1791
1792const SEED_CLOSING: &str = "\
1793Done — the writer now retries atomically and the regression test covers the
1794torn window.
1795
1796";
1797
1798const SEED_DIFF_BEFORE: &str = "\
1799fn reconcile(state: &mut State, incoming: Vec<Delta>) -> Outcome {
1800 let mut applied = Vec::new();
1801 for delta in incoming {
1802 state.apply(&delta);
1803 }
1804 state.commit(Vec::new())
1805}
1806";
1807
1808const SEED_DIFF_AFTER: &str = "\
1809fn reconcile(state: &mut State, incoming: Vec<Delta>) -> Outcome {
1810 let mut applied = Vec::with_capacity(incoming.len());
1811 for delta in incoming {
1812 state.apply(&delta);
1813 applied.push(delta);
1814 }
1815 state.commit(applied)
1816}
1817";
1818
1819fn seed_bash_tool(id: &str) -> AcpEvent {
1820 let mut tool_call = ToolCallUpdate::new(id.to_string()).title(format!("Run {id}"));
1821 tool_call.name = MaybeUndefined::Value("bash".into());
1822 tool_call.raw_input = MaybeUndefined::Value(json!({ "command": "cargo test --module writer" }));
1823 session_update(SessionUpdate::ToolCallUpdate(tool_call))
1824}
1825
1826fn seed_edit_tool(id: &str, turn: usize) -> AcpEvent {
1827 session_update(SessionUpdate::ToolCallUpdate(
1828 ToolCallUpdate::new(id.to_string()).title(format!("Editing src/module_{turn}.rs")),
1829 ))
1830}
1831
1832fn seed_spawn_tool(id: &str) -> AcpEvent {
1833 let mut tool_call = ToolCallUpdate::new(id.to_string()).title(format!("Spawning sub-agents ({id})"));
1834 tool_call.name = MaybeUndefined::Value("spawn_subagent".into());
1835 session_update(SessionUpdate::ToolCallUpdate(tool_call))
1836}
1837
1838fn seed_tool_diff(id: &str, turn: usize) -> AcpEvent {
1839 let diff = text_diff(&format!("/src/module_{turn}.rs"), SEED_DIFF_BEFORE, SEED_DIFF_AFTER);
1840 session_update(acp::SessionUpdate::ToolCallUpdate(
1841 acp::ToolCallUpdate::new(id.to_string())
1842 .content(vec![acp::ToolCallContent::Diff(diff)])
1843 .status(acp::ToolCallStatus::Completed),
1844 ))
1845}
1846
1847fn prose_message(total_bytes: usize) -> String {
1848 let mut message = String::new();
1849 let mut sentence = 0;
1850 while message.len() < total_bytes {
1851 for _ in 0..4 {
1852 let _ =
1853 write!(message, "Sentence {sentence} carries ordinary words so wrapping and parsing do real work. ");
1854 sentence += 1;
1855 }
1856 message.push_str("\n\n");
1857 }
1858 message
1859}
1860
1861fn code_block_message(total_bytes: usize) -> String {
1862 let mut message = String::from("```rust\n");
1863 let mut line = 0;
1864 while message.len() < total_bytes {
1865 let _ = writeln!(message, "let value_{line} = state.reconcile(incoming[{line}]).expect(\"delta accepted\");");
1866 line += 1;
1867 }
1868 message.push_str("```\n");
1869 message
1870}
1871
1872fn thought_message(total_bytes: usize) -> String {
1873 let mut message = String::new();
1874 let mut step = 0;
1875 while message.len() < total_bytes {
1876 let _ = writeln!(message, "Considering step {step} of the plan before acting on it.");
1877 step += 1;
1878 }
1879 message
1880}
1881
1882#[cfg(test)]
1883mod tests {
1884 use super::*;
1885 use crate::command::TerminalCommand;
1886 use crate::git_review::{FileStatus, StageState};
1887
1888 #[test]
1889 fn fake_executor_preserves_command_order() {
1890 let mut executor = FakeExecutor::new();
1891 executor
1892 .record([Command::Filesystem(FilesystemCommand::ListThemes), Command::Terminal(TerminalCommand::RingBell)]);
1893
1894 assert!(matches!(executor.take_commands()[..], [Command::Filesystem(_), Command::Terminal(_)]));
1895 }
1896
1897 #[test]
1898 fn fake_filesystem_persists_files_and_settings_in_memory() {
1899 let mut filesystem = FakeFilesystem::new();
1900 let path = PathBuf::from("workspace/src/main.rs");
1901 filesystem.write_file(&path, "fn main() {}");
1902 filesystem.save_settings(UiSettings::default());
1903
1904 assert_eq!(filesystem.read_to_string(&path).as_deref(), Some("fn main() {}"));
1905 assert!(filesystem.contains(Path::new("workspace/src")));
1906 assert!(filesystem.settings().is_some());
1907 }
1908
1909 #[test]
1910 fn fake_git_models_staging_and_discarding_state() {
1911 let mut git = FakeGit::new("workspace");
1912 git.add_file("src/main.rs", "initial\n");
1913 assert_eq!(git.status("src/main.rs"), Some((FileStatus::Untracked, StageState::Unstaged)));
1914
1915 git.stage("src/main.rs");
1916 assert_eq!(git.status("src/main.rs"), Some((FileStatus::Untracked, StageState::Staged)));
1917 git.commit("initial").unwrap();
1918
1919 git.write_file("src/main.rs", "changed\n");
1920 assert_eq!(git.status("src/main.rs"), Some((FileStatus::Modified, StageState::Unstaged)));
1921 git.discard("src/main.rs");
1922 assert_eq!(git.file("src/main.rs").and_then(|file| file.contents), Some(b"initial\n".to_vec()));
1923 }
1924}