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