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