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};
11use crate::file_index::{FileEntry, MAX_INDEXED_FILES, file_entries};
12use crate::git_review::{
13 DiffDocument, DiffScope, FileDiff, FileStatus, GitDiffError, GitDiffEvent, StageState, build_untracked_file_diff,
14};
15pub use crate::renderer::RenderStats;
16use crate::renderer::Renderer;
17use crate::session::platform::BrowserOpener;
18use crate::session::terminal::inline_viewport_height;
19use crate::session::workspace_status::WorkspaceStatus;
20use crate::settings::UiSettings;
21use crate::surfaces::composer::ComposerLayout;
22use acp_utils::AETHER_TOOL_NAME_META_KEY;
23use acp_utils::client::AcpEvent;
24use acp_utils::notifications::{
25 AetherCapabilities, SubAgentEvent, SubAgentProgressParams, SubAgentToolRequest, SubAgentToolResult,
26};
27use agent_client_protocol::schema::v1::{self as acp, SessionId};
28use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
29use ratatui::backend::{Backend, ClearType, TestBackend, WindowSize};
30use ratatui::buffer::{Buffer, Cell};
31use ratatui::layout::{Position, Rect, Size};
32use ratatui::{Terminal, TerminalOptions, Viewport};
33use serde_json::json;
34use std::collections::{BTreeMap, BTreeSet, VecDeque};
35use std::fmt::Write as _;
36use std::path::{Path, PathBuf};
37use std::sync::{Arc, Mutex};
38use std::time::{Duration, Instant};
39
40pub struct FakeExecutor {
46 available: VecDeque<Command>,
48 pending: VecDeque<Command>,
50 git: FakeGit,
51 filesystem: FakeFilesystem,
52}
53
54impl Default for FakeExecutor {
55 fn default() -> Self {
56 Self::new()
57 }
58}
59
60impl FakeExecutor {
61 pub fn new() -> Self {
62 Self::with_git(FakeGit::default())
63 }
64
65 pub fn with_git(git: FakeGit) -> Self {
66 Self { available: VecDeque::new(), pending: VecDeque::new(), git, filesystem: FakeFilesystem::default() }
67 }
68
69 pub fn git(&self) -> &FakeGit {
70 &self.git
71 }
72
73 pub fn git_mut(&mut self) -> &mut FakeGit {
74 &mut self.git
75 }
76
77 pub fn filesystem(&self) -> &FakeFilesystem {
78 &self.filesystem
79 }
80
81 pub fn filesystem_mut(&mut self) -> &mut FakeFilesystem {
82 &mut self.filesystem
83 }
84
85 pub fn record(&mut self, commands: impl IntoIterator<Item = Command>) {
86 for command in commands {
87 self.available.push_back(command.clone());
88 self.pending.push_back(command);
89 }
90 }
91
92 fn complete(&mut self, command: Command) -> Option<CommandResult> {
93 match command {
94 Command::ResolveWorkspace { cwd } => Some(CommandResult::WorkspaceResolved {
95 status: WorkspaceStatus::new(cwd.display().to_string(), None),
96 cwd,
97 }),
98 Command::Git(command) => Some(CommandResult::GitDiff(self.git.apply(command))),
99 Command::Filesystem(FilesystemCommand::PrepareSubmission { attachments }) => {
100 Some(CommandResult::SubmissionPrepared(self.filesystem.build_attachments(&attachments)))
101 }
102 Command::Filesystem(FilesystemCommand::IndexFiles { request_id, root }) => {
103 Some(CommandResult::FilesIndexed { request_id, files: self.filesystem.index_files(&root) })
104 }
105 _ => None,
106 }
107 }
108
109 fn take_pending(&mut self) -> Vec<Command> {
110 self.pending.drain(..).collect()
111 }
112
113 fn clear_available(&mut self) {
114 self.available.clear();
115 }
116
117 pub fn take_commands(&mut self) -> Vec<Command> {
118 self.pending.clear();
119 self.available.drain(..).collect()
120 }
121}
122
123#[derive(Clone, Default)]
125pub struct FakeFilesystem {
126 files: BTreeMap<PathBuf, Vec<u8>>,
127 directories: BTreeSet<PathBuf>,
128 settings: Option<UiSettings>,
129}
130
131impl FakeFilesystem {
132 pub fn new() -> Self {
133 Self::default()
134 }
135
136 pub fn create_dir(&mut self, path: impl Into<PathBuf>) {
137 self.directories.insert(path.into());
138 }
139
140 pub fn write_file(&mut self, path: impl Into<PathBuf>, contents: impl AsRef<[u8]>) {
141 let path = path.into();
142 if let Some(parent) = path.parent() {
143 self.directories.insert(parent.to_path_buf());
144 }
145 self.files.insert(path, contents.as_ref().to_vec());
146 }
147
148 pub fn remove_file(&mut self, path: &Path) -> bool {
149 self.files.remove(path).is_some()
150 }
151
152 pub fn read_file(&self, path: &Path) -> Option<&[u8]> {
153 self.files.get(path).map(Vec::as_slice)
154 }
155
156 pub fn read_to_string(&self, path: &Path) -> Option<String> {
157 self.read_file(path).and_then(|contents| String::from_utf8(contents.to_vec()).ok())
158 }
159
160 pub fn contains(&self, path: &Path) -> bool {
161 self.files.contains_key(path) || self.directories.contains(path)
162 }
163
164 pub fn files(&self) -> impl Iterator<Item = (&Path, &[u8])> {
165 self.files.iter().map(|(path, contents)| (path.as_path(), contents.as_slice()))
166 }
167
168 pub fn directories(&self) -> impl Iterator<Item = &Path> {
169 self.directories.iter().map(PathBuf::as_path)
170 }
171
172 pub fn save_settings(&mut self, settings: UiSettings) {
173 self.settings = Some(settings);
174 }
175
176 pub fn settings(&self) -> Option<&UiSettings> {
177 self.settings.as_ref()
178 }
179
180 pub fn index_files(&self, root: &Path) -> Vec<FileEntry> {
183 let paths = self.files.keys().filter(|path| path.starts_with(root)).cloned();
184 file_entries(root, paths, MAX_INDEXED_FILES)
185 }
186
187 pub fn build_attachments(&self, attachments: &[PromptAttachment]) -> AttachmentOutcome {
190 build_attachments_with(attachments, |path, display_name| {
191 if self.directories.contains(path) {
192 return Err(format!("Failed to read {display_name}: is a directory"));
193 }
194 self.files.get(path).cloned().ok_or_else(|| format!("Failed to read {display_name}: file not found"))
195 })
196 }
197}
198
199#[derive(Clone, Default)]
202pub struct FakeGit {
203 state: std::sync::Arc<std::sync::Mutex<FakeGitState>>,
204}
205
206#[derive(Default)]
207struct FakeGitState {
208 root: PathBuf,
209 files: BTreeMap<String, FakeGitFile>,
210 commits: Vec<String>,
211 is_repo: bool,
212 commit_error: Option<String>,
213}
214
215#[derive(Clone, Debug, PartialEq, Eq)]
216pub struct FakeGitFile {
217 pub path: String,
218 pub contents: Option<Vec<u8>>,
219 pub staged_contents: Option<Vec<u8>>,
220 pub committed_contents: Option<Vec<u8>>,
221}
222
223impl FakeGit {
224 pub fn new(root: impl Into<PathBuf>) -> Self {
225 let state = FakeGitState { root: root.into(), is_repo: true, ..FakeGitState::default() };
226 Self { state: std::sync::Arc::new(std::sync::Mutex::new(state)) }
227 }
228
229 pub fn not_a_repository(root: impl Into<PathBuf>) -> Self {
230 let state = FakeGitState { root: root.into(), ..FakeGitState::default() };
231 Self { state: std::sync::Arc::new(std::sync::Mutex::new(state)) }
232 }
233
234 pub fn fail_next_commit(&mut self, error: impl Into<String>) {
235 self.state.lock().unwrap().commit_error = Some(error.into());
236 }
237
238 pub fn root(&self) -> PathBuf {
239 self.state.lock().unwrap().root.clone()
240 }
241
242 pub fn add_file(&mut self, path: impl Into<String>, contents: impl AsRef<[u8]>) {
243 let path = path.into();
244 self.state.lock().unwrap().files.insert(
245 path.clone(),
246 FakeGitFile {
247 path,
248 contents: Some(contents.as_ref().to_vec()),
249 staged_contents: None,
250 committed_contents: None,
251 },
252 );
253 }
254
255 pub fn write_file(&mut self, path: impl Into<String>, contents: impl AsRef<[u8]>) {
256 let path = path.into();
257 let mut state = self.state.lock().unwrap();
258 let file = state.files.entry(path.clone()).or_insert_with(|| FakeGitFile {
259 path,
260 contents: None,
261 staged_contents: None,
262 committed_contents: None,
263 });
264 file.contents = Some(contents.as_ref().to_vec());
265 }
266
267 pub fn remove_file(&mut self, path: &str) {
268 if let Some(file) = self.state.lock().unwrap().files.get_mut(path) {
269 file.contents = None;
270 }
271 }
272
273 pub fn stage(&mut self, path: &str) -> bool {
274 let mut state = self.state.lock().unwrap();
275 let Some(file) = state.files.get_mut(path) else { return false };
276 file.staged_contents = file.contents.clone();
277 true
278 }
279
280 pub fn unstage(&mut self, path: &str) -> bool {
281 let mut state = self.state.lock().unwrap();
282 let Some(file) = state.files.get_mut(path) else { return false };
283 file.staged_contents = file.committed_contents.clone();
284 true
285 }
286
287 pub fn stage_all(&mut self) {
288 let mut state = self.state.lock().unwrap();
289 for file in state.files.values_mut() {
290 file.staged_contents = file.contents.clone();
291 }
292 }
293
294 pub fn unstage_all(&mut self) {
295 let mut state = self.state.lock().unwrap();
296 for file in state.files.values_mut() {
297 file.staged_contents = file.committed_contents.clone();
298 }
299 }
300
301 pub fn discard(&mut self, path: &str) -> bool {
302 let mut state = self.state.lock().unwrap();
303 let Some(file) = state.files.get_mut(path) else { return false };
304 file.contents = file.committed_contents.clone();
305 file.staged_contents = file.committed_contents.clone();
306 true
307 }
308
309 pub fn commit(&mut self, message: impl Into<String>) -> Result<(), String> {
310 let message = message.into();
311 let mut state = self.state.lock().unwrap();
312 if message.trim().is_empty() {
313 return Err("empty commit message".to_string());
314 }
315 if !state.files.values().any(|file| file.staged_contents != file.committed_contents) {
316 return Err("nothing to commit".to_string());
317 }
318 for file in state.files.values_mut() {
319 if file.staged_contents != file.committed_contents {
320 file.committed_contents = file.staged_contents.clone();
321 }
322 }
323 state.commits.push(message);
324 Ok(())
325 }
326
327 pub fn file(&self, path: &str) -> Option<FakeGitFile> {
328 self.state.lock().unwrap().files.get(path).cloned()
329 }
330
331 pub fn files(&self) -> Vec<FakeGitFile> {
332 self.state.lock().unwrap().files.values().cloned().collect()
333 }
334
335 pub fn commits(&self) -> Vec<String> {
336 self.state.lock().unwrap().commits.clone()
337 }
338
339 pub fn status(&self, path: &str) -> Option<(FileStatus, StageState)> {
340 self.state.lock().unwrap().files.get(path).and_then(status_of)
341 }
342
343 fn load_diff(&self, scope: DiffScope) -> Result<DiffDocument, GitDiffError> {
344 let state = self.state.lock().unwrap();
345 if !state.is_repo {
346 return Err(GitDiffError::NotARepository);
347 }
348
349 let mut files = Vec::new();
350 for file in state.files.values() {
351 let untracked = file.committed_contents.is_none() && file.staged_contents.is_none();
352 if untracked {
353 if scope.includes_untracked()
354 && let Some(contents) = &file.contents
355 {
356 files.push(build_untracked_file_diff(file.path.clone(), contents));
357 }
358 continue;
359 }
360
361 let (old, new) = match scope {
362 DiffScope::Staged => (&file.committed_contents, &file.staged_contents),
363 DiffScope::Unstaged => {
364 let old =
365 if file.staged_contents.is_some() { &file.staged_contents } else { &file.committed_contents };
366 (old, &file.contents)
367 }
368 DiffScope::Both => (&file.committed_contents, &file.contents),
369 };
370 if old == new {
371 continue;
372 }
373
374 let staged = status_of(file).map_or(StageState::Unstaged, |(_, stage)| stage);
375 let binary = old.as_ref().is_some_and(|bytes| is_binary(bytes))
376 || new.as_ref().is_some_and(|bytes| is_binary(bytes));
377 if binary {
378 let status = match (old, new) {
379 (None, Some(_)) => FileStatus::Added,
380 (Some(_), None) => FileStatus::Deleted,
381 _ => FileStatus::Modified,
382 };
383 files.push(FileDiff {
384 old_path: (status != FileStatus::Added).then(|| file.path.clone()),
385 path: file.path.clone(),
386 status,
387 staged,
388 hunks: Vec::new(),
389 binary: true,
390 });
391 continue;
392 }
393
394 let old_text = old.as_deref().map(bytes_to_text).transpose()?.unwrap_or_default();
395 let new_text = new.as_deref().map(bytes_to_text).transpose()?.unwrap_or_default();
396 let mut diff = FileDiff::from_texts(file.path.clone(), &old_text, &new_text);
397 diff.staged = staged;
398 files.push(diff);
399 }
400 files.sort_by(|left, right| left.path.cmp(&right.path));
401 Ok(DiffDocument { repo_root: state.root.clone(), files })
402 }
403
404 fn read_full_file(&self, path: &str) -> Result<String, GitDiffError> {
405 let state = self.state.lock().unwrap();
406 let Some(contents) = state.files.get(path).and_then(|file| {
407 file.contents.as_deref().or(file.staged_contents.as_deref()).or(file.committed_contents.as_deref())
408 }) else {
409 return Err(GitDiffError::CommandFailed { stderr: format!("Cannot read {path}: file not found") });
410 };
411 String::from_utf8(contents.to_vec())
412 .map_err(|error| GitDiffError::CommandFailed { stderr: format!("Cannot read {path}: {error}") })
413 }
414
415 fn apply(&mut self, command: GitCommand) -> GitDiffEvent {
416 match command {
417 GitCommand::Load { request_id, scope, .. } => {
418 GitDiffEvent::Loaded { request_id, result: self.load_diff(scope) }
419 }
420 GitCommand::StageFiles { request_id, paths, .. } => {
421 for path in paths {
422 self.stage(&path);
423 }
424 GitDiffEvent::ActionFinished { request_id, result: Ok(()) }
425 }
426 GitCommand::UnstageFiles { request_id, paths, .. } => {
427 for path in paths {
428 self.unstage(&path);
429 }
430 GitDiffEvent::ActionFinished { request_id, result: Ok(()) }
431 }
432 GitCommand::StageAll { request_id, .. } => {
433 self.stage_all();
434 GitDiffEvent::ActionFinished { request_id, result: Ok(()) }
435 }
436 GitCommand::UnstageAll { request_id, .. } => {
437 self.unstage_all();
438 GitDiffEvent::ActionFinished { request_id, result: Ok(()) }
439 }
440 GitCommand::Commit { request_id, message, .. } => {
441 let error = self.state.lock().unwrap().commit_error.take();
442 let result = error.map_or_else(
443 || self.commit(message).map_err(|stderr| GitDiffError::CommandFailed { stderr }),
444 |stderr| Err(GitDiffError::CommandFailed { stderr }),
445 );
446 GitDiffEvent::ActionFinished { request_id, result }
447 }
448 GitCommand::DiscardFile { request_id, path, status, .. } => {
449 if status == FileStatus::Untracked {
450 self.state.lock().unwrap().files.remove(&path);
451 } else {
452 self.discard(&path);
453 }
454 GitDiffEvent::ActionFinished { request_id, result: Ok(()) }
455 }
456 GitCommand::LoadFullFile { request_id, path, .. } => {
457 GitDiffEvent::FullFileLoaded { request_id, result: self.read_full_file(&path), path }
458 }
459 }
460 }
461}
462
463fn status_of(file: &FakeGitFile) -> Option<(FileStatus, StageState)> {
464 let staged_changed = file.staged_contents != file.committed_contents;
465 let working_changed = file.contents != file.staged_contents;
466 if !staged_changed && !working_changed {
467 return None;
468 }
469
470 if file.committed_contents.is_none() {
471 let stage = match (file.staged_contents.is_some(), working_changed) {
472 (true, true) => StageState::PartiallyStaged,
473 (true, false) => StageState::Staged,
474 (false, _) => StageState::Unstaged,
475 };
476 return Some((FileStatus::Untracked, stage));
477 }
478
479 let stage = match (staged_changed, working_changed) {
480 (true, true) => StageState::PartiallyStaged,
481 (true, false) => StageState::Staged,
482 (false, true) => StageState::Unstaged,
483 (false, false) => unreachable!("clean files returned above"),
484 };
485 let status = if file.contents.is_none() { FileStatus::Deleted } else { FileStatus::Modified };
486 Some((status, stage))
487}
488
489fn is_binary(bytes: &[u8]) -> bool {
490 bytes.iter().take(8192).any(|byte| *byte == 0) || std::str::from_utf8(bytes).is_err()
491}
492
493fn bytes_to_text(bytes: &[u8]) -> Result<String, GitDiffError> {
494 std::str::from_utf8(bytes)
495 .map(str::to_string)
496 .map_err(|error| GitDiffError::CommandFailed { stderr: error.to_string() })
497}
498
499pub struct TestTerminal {
501 terminal: Terminal<TestBackend>,
502}
503
504impl TestTerminal {
505 pub fn new(width: u16, height: u16) -> Self {
506 Self { terminal: test_terminal(TestBackend::new(width, height)) }
507 }
508
509 pub fn terminal(&mut self) -> &mut Terminal<TestBackend> {
510 &mut self.terminal
511 }
512
513 pub fn resize(&mut self, width: u16, height: u16) {
514 self.terminal.backend_mut().resize(width, height);
515 }
516
517 pub fn viewport(&mut self) -> Buffer {
518 viewport_buffer(&mut self.terminal)
519 }
520
521 pub fn history(&mut self) -> Buffer {
522 history_buffer(&mut self.terminal)
523 }
524
525 pub fn conversation(&mut self) -> Buffer {
526 conversation_buffer(&mut self.terminal)
527 }
528}
529
530#[derive(Debug, Clone, Copy, PartialEq, Eq)]
534pub enum BackendEvent {
535 ShowCursor,
536 Scroll,
537}
538
539#[derive(Debug)]
540pub struct RecordingBackend {
541 inner: TestBackend,
542 events: Vec<BackendEvent>,
543}
544
545impl RecordingBackend {
546 pub fn new(width: u16, height: u16) -> Self {
547 Self { inner: TestBackend::new(width, height), events: Vec::new() }
548 }
549
550 pub fn events(&self) -> &[BackendEvent] {
551 &self.events
552 }
553
554 pub fn clear_events(&mut self) {
555 self.events.clear();
556 }
557
558 pub fn resize(&mut self, width: u16, height: u16) {
559 self.inner.resize(width, height);
560 }
561
562 pub fn buffer(&self) -> &Buffer {
563 self.inner.buffer()
564 }
565
566 pub fn scrollback(&self) -> &Buffer {
567 self.inner.scrollback()
568 }
569}
570
571impl Backend for RecordingBackend {
572 type Error = std::convert::Infallible;
573
574 fn draw<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
575 where
576 I: Iterator<Item = (u16, u16, &'a Cell)>,
577 {
578 self.inner.draw(content)
579 }
580
581 fn append_lines(&mut self, lines: u16) -> Result<(), Self::Error> {
582 self.inner.append_lines(lines)
583 }
584
585 fn hide_cursor(&mut self) -> Result<(), Self::Error> {
586 self.inner.hide_cursor()
587 }
588
589 fn show_cursor(&mut self) -> Result<(), Self::Error> {
590 self.events.push(BackendEvent::ShowCursor);
591 self.inner.show_cursor()
592 }
593
594 fn get_cursor_position(&mut self) -> Result<Position, Self::Error> {
595 self.inner.get_cursor_position()
596 }
597
598 fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> Result<(), Self::Error> {
599 self.inner.set_cursor_position(position)
600 }
601
602 fn clear(&mut self) -> Result<(), Self::Error> {
603 self.inner.clear()
604 }
605
606 fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error> {
607 self.inner.clear_region(clear_type)
608 }
609
610 fn size(&self) -> Result<Size, Self::Error> {
611 self.inner.size()
612 }
613
614 fn window_size(&mut self) -> Result<WindowSize, Self::Error> {
615 self.inner.window_size()
616 }
617
618 fn flush(&mut self) -> Result<(), Self::Error> {
619 self.inner.flush()
620 }
621
622 fn scroll_region_up(&mut self, region: std::ops::Range<u16>, lines: u16) -> Result<(), Self::Error> {
623 self.events.push(BackendEvent::Scroll);
624 let region = if region == (0..1) { 0..self.inner.size().unwrap().height } else { region };
625 self.inner.scroll_region_up(region, lines)
626 }
627
628 fn scroll_region_down(&mut self, region: std::ops::Range<u16>, lines: u16) -> Result<(), Self::Error> {
629 self.events.push(BackendEvent::Scroll);
630 self.inner.scroll_region_down(region, lines)
631 }
632}
633
634#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
639pub struct BackendStats {
640 pub draws: u64,
641 pub cells_drawn: u64,
642 pub scrolls: u64,
643}
644
645#[derive(Debug)]
648pub struct CountingBackend {
649 inner: TestBackend,
650 stats: BackendStats,
651}
652
653impl CountingBackend {
654 pub fn new(width: u16, height: u16) -> Self {
655 Self { inner: TestBackend::new(width, height), stats: BackendStats::default() }
656 }
657
658 pub fn take_stats(&mut self) -> BackendStats {
659 std::mem::take(&mut self.stats)
660 }
661
662 pub fn buffer(&self) -> &Buffer {
663 self.inner.buffer()
664 }
665
666 pub fn scrollback(&self) -> &Buffer {
667 self.inner.scrollback()
668 }
669}
670
671impl Backend for CountingBackend {
672 type Error = std::convert::Infallible;
673
674 fn draw<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
675 where
676 I: Iterator<Item = (u16, u16, &'a Cell)>,
677 {
678 self.stats.draws += 1;
679 let mut cells = 0u64;
680 let drawn = self.inner.draw(content.inspect(|_| cells += 1));
681 self.stats.cells_drawn += cells;
682 drawn
683 }
684
685 fn hide_cursor(&mut self) -> Result<(), Self::Error> {
686 self.inner.hide_cursor()
687 }
688
689 fn show_cursor(&mut self) -> Result<(), Self::Error> {
690 self.inner.show_cursor()
691 }
692
693 fn get_cursor_position(&mut self) -> Result<Position, Self::Error> {
694 self.inner.get_cursor_position()
695 }
696
697 fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> Result<(), Self::Error> {
698 self.inner.set_cursor_position(position)
699 }
700
701 fn clear(&mut self) -> Result<(), Self::Error> {
702 self.inner.clear()
703 }
704
705 fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error> {
706 self.inner.clear_region(clear_type)
707 }
708
709 fn size(&self) -> Result<Size, Self::Error> {
710 self.inner.size()
711 }
712
713 fn window_size(&mut self) -> Result<WindowSize, Self::Error> {
714 self.inner.window_size()
715 }
716
717 fn flush(&mut self) -> Result<(), Self::Error> {
718 self.inner.flush()
719 }
720
721 fn scroll_region_up(&mut self, region: std::ops::Range<u16>, lines: u16) -> Result<(), Self::Error> {
722 self.stats.scrolls += 1;
723 self.inner.scroll_region_up(region, lines)
724 }
725
726 fn scroll_region_down(&mut self, region: std::ops::Range<u16>, lines: u16) -> Result<(), Self::Error> {
727 self.stats.scrolls += 1;
728 self.inner.scroll_region_down(region, lines)
729 }
730}
731
732pub struct TestUi<B: Backend = TestBackend> {
740 app: App,
741 renderer: Renderer,
742 terminal: Terminal<B>,
743 executor: FakeExecutor,
744 opened_urls: Arc<Mutex<Vec<String>>>,
745}
746
747impl<B: Backend> TestUi<B>
748where
749 B::Error: std::fmt::Debug,
750{
751 pub fn with_backend(backend: B) -> Self {
755 let builder = TestUiBuilder::new();
756 let app = App::new(builder.app_config());
757 Self {
758 app,
759 renderer: Renderer::new(),
760 terminal: test_terminal(backend),
761 executor: FakeExecutor::new(),
762 opened_urls: builder.opened_urls.clone(),
763 }
764 }
765
766 pub fn app(&self) -> &App {
770 &self.app
771 }
772
773 pub fn deliver(&mut self, message: Message) {
776 self.executor.record(self.app.update(message));
777 }
778
779 pub fn deliver_result(&mut self, result: CommandResult) {
780 self.deliver(Message::CommandFinished(result));
781 }
782
783 pub fn executor(&self) -> &FakeExecutor {
785 &self.executor
786 }
787
788 pub fn executor_mut(&mut self) -> &mut FakeExecutor {
789 &mut self.executor
790 }
791
792 pub fn take_commands(&mut self) -> Vec<Command> {
793 self.executor.take_commands()
794 }
795
796 pub fn next_command(&mut self) -> Option<Command> {
797 self.executor.available.pop_front()
798 }
799
800 pub fn next_agent_command(&mut self) -> Option<AgentCommand> {
801 while let Some(command) = self.next_command() {
802 if let Command::Agent(command) = command {
803 return Some(command);
804 }
805 }
806 None
807 }
808
809 pub fn backend(&self) -> &B {
810 self.terminal.backend()
811 }
812
813 pub fn backend_mut(&mut self) -> &mut B {
814 self.terminal.backend_mut()
815 }
816
817 pub fn viewport_area(&mut self) -> ratatui::layout::Rect {
818 self.terminal.get_frame().area()
819 }
820
821 pub fn viewport_height(&mut self) -> u16 {
822 self.viewport_area().height
823 }
824
825 pub fn composer_layout(&mut self, width: u16) -> ComposerLayout {
827 let theme = self.app.theme().clone();
828 let composer = self.app.composer_mut();
829 composer.on_resize(width);
830 composer.layout(width, &theme)
831 }
832
833 pub fn draw(&mut self) {
835 self.renderer.draw(&mut self.terminal, &mut self.app).unwrap();
836 }
837
838 pub fn render_stats(&mut self) -> RenderStats {
839 self.renderer.take_stats()
840 }
841
842 pub fn opened_urls(&self) -> Vec<String> {
845 self.opened_urls.lock().unwrap().clone()
846 }
847
848 pub fn seed_long_history(&mut self, turns: usize) {
853 for turn in 0..turns {
854 self.acp_event(user_chunk(&format!(
855 "Turn {turn}: reconcile the writer path in module_{turn} and add a regression test."
856 )));
857 self.acp_event(thought_chunk(&format!(
858 "Reading module_{turn} to find the torn-update window before touching any call site."
859 )));
860 self.acp_event(text_chunk(SEED_PROSE));
861 self.acp_event(text_chunk(SEED_CODE_BLOCK));
862 let bash = format!("seed-bash-{turn}");
863 self.acp_event(seed_bash_tool(&bash));
864 self.acp_event(tool_completed(&bash));
865 let edit = format!("seed-edit-{turn}");
866 self.acp_event(seed_edit_tool(&edit, turn));
867 self.acp_event(seed_tool_diff(&edit, turn));
868 if turn % 8 == 0 {
869 self.seed_sub_agent_tree(turn);
870 }
871 self.acp_event(text_chunk(SEED_CLOSING));
872 self.acp_event(AcpEvent::PromptDone(acp::StopReason::EndTurn));
873 self.draw();
874 }
875 }
876
877 fn seed_sub_agent_tree(&mut self, turn: usize) {
879 let parent = format!("seed-spawn-{turn}");
880 self.acp_event(seed_spawn_tool(&parent));
881 self.acp_event(tool_completed(&parent));
882 for agent in ["explorer", "fixer"] {
883 let task = format!("{parent}-{agent}");
884 self.acp_event(seed_sub_agent(
885 &parent,
886 &task,
887 agent,
888 SubAgentEvent::ToolCall {
889 request: SubAgentToolRequest {
890 id: format!("{task}-grep"),
891 name: "grep".to_string(),
892 arguments: r#"{"pattern":"torn update"}"#.to_string(),
893 },
894 },
895 ));
896 self.acp_event(seed_sub_agent(
897 &parent,
898 &task,
899 agent,
900 SubAgentEvent::ToolResult {
901 result: SubAgentToolResult {
902 id: format!("{task}-grep"),
903 name: "grep".to_string(),
904 result_meta: None,
905 },
906 },
907 ));
908 self.acp_event(seed_sub_agent(&parent, &task, agent, SubAgentEvent::Done));
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.acp_event(AcpEvent::PromptDone(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 tick(&mut self, now: Instant) {
981 self.deliver(Message::Tick(now));
982 }
983
984 pub fn settle_tasks(&mut self) {
987 self.executor.clear_available();
988 let mut initial_batch = true;
989 loop {
990 let pending = self.executor.take_pending();
991 if pending.is_empty() {
992 return;
993 }
994 if initial_batch {
995 for command in &pending {
996 if matches!(command, Command::Agent(_)) {
997 self.executor.available.push_back(command.clone());
998 }
999 }
1000 }
1001 for command in pending {
1002 if let Some(result) = self.executor.complete(command) {
1003 self.deliver(Message::CommandFinished(result));
1004 }
1005 }
1006 initial_batch = false;
1007 }
1008 }
1009}
1010
1011impl TestUi<TestBackend> {
1012 pub fn new() -> Self {
1014 Self::with_dimensions(40, 15)
1015 }
1016
1017 pub fn with_dimensions(width: u16, height: u16) -> Self {
1018 TestUiBuilder::new().dimensions(width, height).build()
1019 }
1020
1021 pub fn resize(&mut self, width: u16, height: u16) {
1024 self.terminal.backend_mut().resize(width, height);
1025 }
1026}
1027
1028impl Default for TestUi<TestBackend> {
1029 fn default() -> Self {
1030 Self::new()
1031 }
1032}
1033
1034pub trait BuffersReader {
1037 fn screen(&self) -> &Buffer;
1038 fn scrollback(&self) -> &Buffer;
1039}
1040
1041impl BuffersReader for TestBackend {
1042 fn screen(&self) -> &Buffer {
1043 self.buffer()
1044 }
1045
1046 fn scrollback(&self) -> &Buffer {
1047 self.scrollback()
1048 }
1049}
1050
1051impl BuffersReader for CountingBackend {
1052 fn screen(&self) -> &Buffer {
1053 self.buffer()
1054 }
1055
1056 fn scrollback(&self) -> &Buffer {
1057 self.scrollback()
1058 }
1059}
1060
1061impl BuffersReader for RecordingBackend {
1062 fn screen(&self) -> &Buffer {
1063 self.buffer()
1064 }
1065
1066 fn scrollback(&self) -> &Buffer {
1067 self.scrollback()
1068 }
1069}
1070
1071impl<B> TestUi<B>
1072where
1073 B: Backend + BuffersReader,
1074 B::Error: std::fmt::Debug,
1075{
1076 pub fn viewport(&mut self) -> Buffer {
1080 self.draw();
1081 viewport_buffer(&mut self.terminal)
1082 }
1083
1084 pub fn history(&mut self) -> Buffer {
1087 self.draw();
1088 history_buffer(&mut self.terminal)
1089 }
1090
1091 pub fn conversation(&mut self) -> Buffer {
1094 self.draw();
1095 conversation_buffer(&mut self.terminal)
1096 }
1097
1098 pub fn viewport_text(&mut self) -> String {
1099 buffer_text(&self.viewport())
1100 }
1101
1102 pub fn history_text(&mut self) -> String {
1103 buffer_text(&self.history())
1104 }
1105
1106 pub fn conversation_text(&mut self) -> String {
1107 buffer_text(&self.conversation())
1108 }
1109
1110 pub fn viewport_row(&mut self, needle: &str) -> Option<u16> {
1112 row_containing(&self.viewport(), needle)
1113 }
1114
1115 pub fn assert_viewport_contains(&mut self, needle: &str) {
1116 let viewport = self.viewport_text();
1117 assert!(
1118 viewport.contains(needle),
1119 "viewport should contain {needle:?}:
1120{viewport}"
1121 );
1122 }
1123
1124 pub fn assert_viewport_not_contains(&mut self, needle: &str) {
1125 let viewport = self.viewport_text();
1126 assert!(
1127 !viewport.contains(needle),
1128 "viewport should not contain {needle:?}:
1129{viewport}"
1130 );
1131 }
1132
1133 pub fn assert_history_contains(&mut self, needle: &str) {
1134 let history = self.history_text();
1135 assert!(
1136 history.contains(needle),
1137 "history should contain {needle:?}:
1138{history}"
1139 );
1140 }
1141
1142 pub fn assert_history_not_contains(&mut self, needle: &str) {
1143 let history = self.history_text();
1144 assert!(
1145 !history.contains(needle),
1146 "history should not contain {needle:?}:
1147{history}"
1148 );
1149 }
1150
1151 pub fn assert_conversation_contains(&mut self, needle: &str) {
1152 let conversation = self.conversation_text();
1153 assert!(
1154 conversation.contains(needle),
1155 "conversation should contain {needle:?}:
1156{conversation}"
1157 );
1158 }
1159
1160 pub fn assert_conversation_not_contains(&mut self, needle: &str) {
1161 let conversation = self.conversation_text();
1162 assert!(
1163 !conversation.contains(needle),
1164 "conversation should not contain {needle:?}:
1165{conversation}"
1166 );
1167 }
1168
1169 pub fn assert_viewport<S: AsRef<str>>(&mut self, expected: &[S]) {
1171 assert_buffer_eq(&self.viewport(), expected);
1172 }
1173
1174 pub fn assert_history<S: AsRef<str>>(&mut self, expected: &[S]) {
1176 assert_buffer_eq(&self.history(), expected);
1177 }
1178
1179 pub fn assert_conversation<S: AsRef<str>>(&mut self, expected: &[S]) {
1181 assert_buffer_eq(&self.conversation(), expected);
1182 }
1183}
1184
1185pub struct TestUiBuilder {
1188 width: u16,
1189 height: u16,
1190 working_dir: Option<PathBuf>,
1191 capabilities: AetherCapabilities,
1192 prompt_capabilities: acp::PromptCapabilities,
1193 config_options: Vec<acp::SessionConfigOption>,
1194 auth_methods: Vec<acp::AuthMethod>,
1195 session_capabilities: Option<acp::SessionCapabilities>,
1196 settings: UiSettings,
1197 workspace_status: Option<WorkspaceStatus>,
1198 git: FakeGit,
1199 opened_urls: Arc<Mutex<Vec<String>>>,
1200}
1201
1202impl Default for TestUiBuilder {
1203 fn default() -> Self {
1204 Self {
1205 width: 40,
1206 height: 15,
1207 working_dir: None,
1208 capabilities: AetherCapabilities::default(),
1209 prompt_capabilities: acp::PromptCapabilities::new(),
1210 config_options: Vec::new(),
1211 auth_methods: Vec::new(),
1212 session_capabilities: None,
1213 settings: UiSettings::default(),
1214 workspace_status: None,
1215 git: FakeGit::default(),
1216 opened_urls: Arc::new(Mutex::new(Vec::new())),
1217 }
1218 }
1219}
1220
1221impl TestUiBuilder {
1222 pub fn new() -> Self {
1223 Self::default()
1224 }
1225
1226 pub fn dimensions(mut self, width: u16, height: u16) -> Self {
1227 self.width = width;
1228 self.height = height;
1229 self
1230 }
1231
1232 pub fn working_dir(mut self, working_dir: impl Into<PathBuf>) -> Self {
1233 self.working_dir = Some(working_dir.into());
1234 self
1235 }
1236
1237 pub fn prompt_capabilities(mut self, capabilities: acp::PromptCapabilities) -> Self {
1238 self.prompt_capabilities = capabilities;
1239 self
1240 }
1241
1242 pub fn config_options(mut self, options: Vec<acp::SessionConfigOption>) -> Self {
1243 self.config_options = options;
1244 self
1245 }
1246
1247 pub fn auth_methods(mut self, methods: Vec<acp::AuthMethod>) -> Self {
1248 self.auth_methods = methods;
1249 self
1250 }
1251
1252 pub fn settings(mut self, settings: UiSettings) -> Self {
1253 self.settings = settings;
1254 self
1255 }
1256
1257 pub fn workspace_status(mut self, workspace_status: WorkspaceStatus) -> Self {
1258 self.workspace_status = Some(workspace_status);
1259 self
1260 }
1261
1262 pub fn git(mut self, git: FakeGit) -> Self {
1263 self.git = git;
1264 self
1265 }
1266
1267 pub fn session_capabilities(mut self, capabilities: acp::SessionCapabilities) -> Self {
1270 self.session_capabilities = Some(capabilities);
1271 self
1272 }
1273
1274 pub fn prompt_search(mut self) -> Self {
1275 self.capabilities.prompt_search = true;
1276 self
1277 }
1278
1279 pub fn session_preview(mut self) -> Self {
1280 self.capabilities.session_preview = true;
1281 self
1282 }
1283
1284 pub fn workspace_move(mut self) -> Self {
1285 self.capabilities.workspace_move = true;
1286 self
1287 }
1288
1289 pub fn build(self) -> TestUi {
1291 self.finish()
1292 }
1293
1294 fn finish(self) -> TestUi {
1295 let app = App::new(self.app_config());
1296 TestUi {
1297 app,
1298 renderer: Renderer::new(),
1299 terminal: test_terminal(TestBackend::new(self.width, self.height)),
1300 executor: FakeExecutor::with_git(self.git),
1301 opened_urls: self.opened_urls,
1302 }
1303 }
1304
1305 fn app_config(&self) -> AppConfig {
1306 let session_capabilities = self
1307 .session_capabilities
1308 .clone()
1309 .unwrap_or_else(|| acp::SessionCapabilities::new().meta(Some(self.capabilities.clone().to_meta())));
1310 AppConfig {
1311 session_id: SessionId::new("test-session"),
1312 agent_name: "aether".to_string(),
1313 prompt_capabilities: self.prompt_capabilities.clone(),
1314 session_capabilities,
1315 config_options: self.config_options.clone(),
1316 auth_methods: self.auth_methods.clone(),
1317 workspace_status: self
1318 .workspace_status
1319 .clone()
1320 .unwrap_or_else(|| WorkspaceStatus::new("~/code/demo", Some("main".to_string()))),
1321 working_dir: self.working_dir.clone().unwrap_or_else(|| PathBuf::from(".")),
1322 settings: self.settings.clone(),
1323 browser_opener: {
1324 let opened = self.opened_urls.clone();
1325 Arc::new(move |url: &str| {
1326 opened.lock().unwrap().push(url.to_string());
1327 Ok(())
1328 }) as BrowserOpener
1329 },
1330 clipboard_writer: Arc::new(|_| Ok(())),
1331 }
1332 }
1333}
1334
1335fn test_terminal<B: Backend>(backend: B) -> Terminal<B>
1338where
1339 B::Error: std::fmt::Debug,
1340{
1341 let height = backend.size().unwrap().height;
1342 Terminal::with_options(backend, TerminalOptions { viewport: Viewport::Inline(inline_viewport_height(height)) })
1343 .unwrap()
1344}
1345
1346fn viewport_buffer<B>(terminal: &mut Terminal<B>) -> Buffer
1349where
1350 B: Backend + BuffersReader,
1351{
1352 let area = terminal.get_frame().area();
1353 let screen = terminal.backend().screen();
1354 let mut viewport = Buffer::empty(Rect::new(0, 0, area.width, area.height));
1355 for y in 0..area.height {
1356 for x in 0..area.width {
1357 viewport[(x, y)] = screen[(area.x + x, area.y + y)].clone();
1358 }
1359 }
1360 viewport
1361}
1362
1363fn history_buffer<B>(terminal: &mut Terminal<B>) -> Buffer
1365where
1366 B: Backend + BuffersReader,
1367{
1368 let viewport_area = terminal.get_frame().area();
1369 let screen = terminal.backend().screen();
1370 let scrollback = terminal.backend().scrollback();
1371 let history_height = scrollback.area.height.saturating_add(viewport_area.top());
1372 let mut history = Buffer::empty(Rect::new(0, 0, screen.area.width, history_height));
1373 for y in 0..scrollback.area.height {
1374 for x in 0..scrollback.area.width {
1375 history[(x, y)] = scrollback[(x, y)].clone();
1376 }
1377 }
1378 for y in 0..viewport_area.top() {
1379 for x in 0..screen.area.width {
1380 history[(x, scrollback.area.height + y)] = screen[(x, y)].clone();
1381 }
1382 }
1383 history
1384}
1385
1386fn conversation_buffer<B>(terminal: &mut Terminal<B>) -> Buffer
1387where
1388 B: Backend + BuffersReader,
1389{
1390 let history = history_buffer(terminal);
1391 let viewport = viewport_buffer(terminal);
1392 let mut conversation =
1393 Buffer::empty(Rect::new(0, 0, viewport.area.width, history.area.height.saturating_add(viewport.area.height)));
1394 for y in 0..history.area.height {
1395 for x in 0..history.area.width {
1396 conversation[(x, y)] = history[(x, y)].clone();
1397 }
1398 }
1399 for y in 0..viewport.area.height {
1400 for x in 0..viewport.area.width {
1401 conversation[(x, history.area.height + y)] = viewport[(x, y)].clone();
1402 }
1403 }
1404 conversation
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
1493const SEED_PROSE: &str = "\
1494Examining the request. The module guards its invariants behind a shared handle,
1495so the fix has to land on the writer side rather than at each call site. I will
1496rework the boundary so retries cannot observe a torn update, then cover the
1497regression with a test that fails on the current code.
1498
1499";
1500
1501const SEED_CODE_BLOCK: &str = "\
1502```rust
1503fn reconcile(state: &mut State, incoming: Vec<Delta>) -> Outcome {
1504 let mut applied = Vec::with_capacity(incoming.len());
1505 for delta in incoming {
1506 if !state.accepts(&delta) {
1507 continue;
1508 }
1509 state.apply(&delta);
1510 applied.push(delta);
1511 }
1512 state.commit(applied)
1513}
1514```
1515
1516";
1517
1518const SEED_CLOSING: &str = "\
1519Done — the writer now retries atomically and the regression test covers the
1520torn window.
1521
1522";
1523
1524const SEED_DIFF_BEFORE: &str = "\
1525fn reconcile(state: &mut State, incoming: Vec<Delta>) -> Outcome {
1526 let mut applied = Vec::new();
1527 for delta in incoming {
1528 state.apply(&delta);
1529 }
1530 state.commit(Vec::new())
1531}
1532";
1533
1534const SEED_DIFF_AFTER: &str = "\
1535fn reconcile(state: &mut State, incoming: Vec<Delta>) -> Outcome {
1536 let mut applied = Vec::with_capacity(incoming.len());
1537 for delta in incoming {
1538 state.apply(&delta);
1539 applied.push(delta);
1540 }
1541 state.commit(applied)
1542}
1543";
1544
1545pub fn session_update(update: acp::SessionUpdate) -> AcpEvent {
1546 AcpEvent::SessionUpdate { session_id: SessionId::new("test-session"), update: Box::new(update) }
1547}
1548
1549fn user_chunk(text: &str) -> AcpEvent {
1550 session_update(acp::SessionUpdate::UserMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
1551 acp::TextContent::new(text),
1552 ))))
1553}
1554
1555pub fn text_chunk(text: &str) -> AcpEvent {
1556 session_update(acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
1557 acp::TextContent::new(text),
1558 ))))
1559}
1560
1561pub fn thought_chunk(text: &str) -> AcpEvent {
1562 session_update(acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
1563 acp::TextContent::new(text),
1564 ))))
1565}
1566
1567fn seed_bash_tool(id: &str) -> AcpEvent {
1568 let mut tool_call = acp::ToolCall::new(id.to_string(), format!("Run {id}"));
1569 tool_call.meta = Some(seed_tool_meta("bash"));
1570 tool_call.raw_input = Some(json!({ "command": "cargo test --module writer" }));
1571 session_update(acp::SessionUpdate::ToolCall(tool_call))
1572}
1573
1574fn seed_edit_tool(id: &str, turn: usize) -> AcpEvent {
1575 session_update(acp::SessionUpdate::ToolCall(acp::ToolCall::new(
1576 id.to_string(),
1577 format!("Editing src/module_{turn}.rs"),
1578 )))
1579}
1580
1581fn seed_spawn_tool(id: &str) -> AcpEvent {
1582 let mut tool_call = acp::ToolCall::new(id.to_string(), format!("Spawning sub-agents ({id})"));
1583 tool_call.meta = Some(seed_tool_meta("spawn_subagent"));
1584 session_update(acp::SessionUpdate::ToolCall(tool_call))
1585}
1586
1587fn seed_tool_meta(tool_name: &str) -> acp::Meta {
1588 let mut meta = serde_json::Map::new();
1589 meta.insert(AETHER_TOOL_NAME_META_KEY.to_string(), json!(tool_name));
1590 meta
1591}
1592
1593pub fn tool_completed(id: &str) -> AcpEvent {
1594 session_update(acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
1595 id.to_string(),
1596 acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed),
1597 )))
1598}
1599
1600fn seed_tool_diff(id: &str, turn: usize) -> AcpEvent {
1601 let diff = acp::Diff::new(format!("src/module_{turn}.rs"), SEED_DIFF_AFTER).old_text(SEED_DIFF_BEFORE);
1602 session_update(acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
1603 id.to_string(),
1604 acp::ToolCallUpdateFields::new()
1605 .content(vec![acp::ToolCallContent::Diff(diff)])
1606 .status(acp::ToolCallStatus::Completed),
1607 )))
1608}
1609
1610fn seed_sub_agent(parent: &str, task: &str, agent: &str, event: SubAgentEvent) -> AcpEvent {
1611 AcpEvent::SubAgentProgress(SubAgentProgressParams {
1612 parent_tool_id: parent.to_string(),
1613 task_id: task.to_string(),
1614 agent_name: agent.to_string(),
1615 event,
1616 })
1617}
1618
1619fn prose_message(total_bytes: usize) -> String {
1620 let mut message = String::new();
1621 let mut sentence = 0;
1622 while message.len() < total_bytes {
1623 for _ in 0..4 {
1624 let _ =
1625 write!(message, "Sentence {sentence} carries ordinary words so wrapping and parsing do real work. ");
1626 sentence += 1;
1627 }
1628 message.push_str("\n\n");
1629 }
1630 message
1631}
1632
1633fn code_block_message(total_bytes: usize) -> String {
1634 let mut message = String::from("```rust\n");
1635 let mut line = 0;
1636 while message.len() < total_bytes {
1637 let _ = writeln!(message, "let value_{line} = state.reconcile(incoming[{line}]).expect(\"delta accepted\");");
1638 line += 1;
1639 }
1640 message.push_str("```\n");
1641 message
1642}
1643
1644fn thought_message(total_bytes: usize) -> String {
1645 let mut message = String::new();
1646 let mut step = 0;
1647 while message.len() < total_bytes {
1648 let _ = writeln!(message, "Considering step {step} of the plan before acting on it.");
1649 step += 1;
1650 }
1651 message
1652}
1653
1654pub fn chunk_message(message: &str, chunk_bytes: usize) -> Vec<String> {
1655 let mut chunks = Vec::new();
1656 let mut rest = message;
1657 while !rest.is_empty() {
1658 let mut end = rest.len().min(chunk_bytes);
1659 while !rest.is_char_boundary(end) {
1660 end -= 1;
1661 }
1662 chunks.push(rest[..end].to_string());
1663 rest = &rest[end..];
1664 }
1665 chunks
1666}
1667
1668#[cfg(test)]
1669mod tests {
1670 use super::*;
1671 use crate::command::TerminalCommand;
1672 use crate::git_review::{FileStatus, StageState};
1673
1674 #[test]
1675 fn fake_executor_preserves_command_order() {
1676 let mut executor = FakeExecutor::new();
1677 executor
1678 .record([Command::Filesystem(FilesystemCommand::ListThemes), Command::Terminal(TerminalCommand::RingBell)]);
1679
1680 assert!(matches!(executor.take_commands()[..], [Command::Filesystem(_), Command::Terminal(_)]));
1681 }
1682
1683 #[test]
1684 fn fake_filesystem_persists_files_and_settings_in_memory() {
1685 let mut filesystem = FakeFilesystem::new();
1686 let path = PathBuf::from("workspace/src/main.rs");
1687 filesystem.write_file(&path, "fn main() {}");
1688 filesystem.save_settings(UiSettings::default());
1689
1690 assert_eq!(filesystem.read_to_string(&path).as_deref(), Some("fn main() {}"));
1691 assert!(filesystem.contains(Path::new("workspace/src")));
1692 assert!(filesystem.settings().is_some());
1693 }
1694
1695 #[test]
1696 fn fake_git_models_staging_and_discarding_state() {
1697 let mut git = FakeGit::new("workspace");
1698 git.add_file("src/main.rs", "initial\n");
1699 assert_eq!(git.status("src/main.rs"), Some((FileStatus::Untracked, StageState::Unstaged)));
1700
1701 git.stage("src/main.rs");
1702 assert_eq!(git.status("src/main.rs"), Some((FileStatus::Untracked, StageState::Staged)));
1703 git.commit("initial").unwrap();
1704
1705 git.write_file("src/main.rs", "changed\n");
1706 assert_eq!(git.status("src/main.rs"), Some((FileStatus::Modified, StageState::Unstaged)));
1707 git.discard("src/main.rs");
1708 assert_eq!(git.file("src/main.rs").and_then(|file| file.contents), Some(b"initial\n".to_vec()));
1709 }
1710}