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(Box::new(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.complete_prompt(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.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 complete_prompt(&mut self, stop_reason: acp::StopReason) {
981 self.acp_event(AcpEvent::PromptCompleted(stop_reason));
982 }
983
984 pub fn tick(&mut self, now: Instant) {
985 self.deliver(Message::Tick(now));
986 }
987
988 pub fn settle_tasks(&mut self) {
991 self.executor.clear_available();
992 let mut initial_batch = true;
993 loop {
994 let pending = self.executor.take_pending();
995 if pending.is_empty() {
996 return;
997 }
998 if initial_batch {
999 for command in &pending {
1000 if matches!(command, Command::Agent(_)) {
1001 self.executor.available.push_back(command.clone());
1002 }
1003 }
1004 }
1005 for command in pending {
1006 if let Some(result) = self.executor.complete(command) {
1007 self.deliver(Message::CommandFinished(Box::new(result)));
1008 }
1009 }
1010 initial_batch = false;
1011 }
1012 }
1013}
1014
1015impl TestUi<TestBackend> {
1016 pub fn new() -> Self {
1018 Self::with_dimensions(40, 15)
1019 }
1020
1021 pub fn with_dimensions(width: u16, height: u16) -> Self {
1022 TestUiBuilder::new().dimensions(width, height).build()
1023 }
1024
1025 pub fn resize(&mut self, width: u16, height: u16) {
1028 self.terminal.backend_mut().resize(width, height);
1029 }
1030}
1031
1032impl Default for TestUi<TestBackend> {
1033 fn default() -> Self {
1034 Self::new()
1035 }
1036}
1037
1038pub trait BuffersReader {
1041 fn screen(&self) -> &Buffer;
1042 fn scrollback(&self) -> &Buffer;
1043}
1044
1045impl BuffersReader for TestBackend {
1046 fn screen(&self) -> &Buffer {
1047 self.buffer()
1048 }
1049
1050 fn scrollback(&self) -> &Buffer {
1051 self.scrollback()
1052 }
1053}
1054
1055impl BuffersReader for CountingBackend {
1056 fn screen(&self) -> &Buffer {
1057 self.buffer()
1058 }
1059
1060 fn scrollback(&self) -> &Buffer {
1061 self.scrollback()
1062 }
1063}
1064
1065impl BuffersReader for RecordingBackend {
1066 fn screen(&self) -> &Buffer {
1067 self.buffer()
1068 }
1069
1070 fn scrollback(&self) -> &Buffer {
1071 self.scrollback()
1072 }
1073}
1074
1075impl<B> TestUi<B>
1076where
1077 B: Backend + BuffersReader,
1078 B::Error: std::fmt::Debug,
1079{
1080 pub fn viewport(&mut self) -> Buffer {
1084 self.draw();
1085 viewport_buffer(&mut self.terminal)
1086 }
1087
1088 pub fn history(&mut self) -> Buffer {
1091 self.draw();
1092 history_buffer(&mut self.terminal)
1093 }
1094
1095 pub fn conversation(&mut self) -> Buffer {
1098 self.draw();
1099 conversation_buffer(&mut self.terminal)
1100 }
1101
1102 pub fn viewport_text(&mut self) -> String {
1103 buffer_text(&self.viewport())
1104 }
1105
1106 pub fn history_text(&mut self) -> String {
1107 buffer_text(&self.history())
1108 }
1109
1110 pub fn conversation_text(&mut self) -> String {
1111 buffer_text(&self.conversation())
1112 }
1113
1114 pub fn viewport_row(&mut self, needle: &str) -> Option<u16> {
1116 row_containing(&self.viewport(), needle)
1117 }
1118
1119 pub fn assert_viewport_contains(&mut self, needle: &str) {
1120 let viewport = self.viewport_text();
1121 assert!(
1122 viewport.contains(needle),
1123 "viewport should contain {needle:?}:
1124{viewport}"
1125 );
1126 }
1127
1128 pub fn assert_viewport_not_contains(&mut self, needle: &str) {
1129 let viewport = self.viewport_text();
1130 assert!(
1131 !viewport.contains(needle),
1132 "viewport should not contain {needle:?}:
1133{viewport}"
1134 );
1135 }
1136
1137 pub fn assert_history_contains(&mut self, needle: &str) {
1138 let history = self.history_text();
1139 assert!(
1140 history.contains(needle),
1141 "history should contain {needle:?}:
1142{history}"
1143 );
1144 }
1145
1146 pub fn assert_history_not_contains(&mut self, needle: &str) {
1147 let history = self.history_text();
1148 assert!(
1149 !history.contains(needle),
1150 "history should not contain {needle:?}:
1151{history}"
1152 );
1153 }
1154
1155 pub fn assert_conversation_contains(&mut self, needle: &str) {
1156 let conversation = self.conversation_text();
1157 assert!(
1158 conversation.contains(needle),
1159 "conversation should contain {needle:?}:
1160{conversation}"
1161 );
1162 }
1163
1164 pub fn assert_conversation_not_contains(&mut self, needle: &str) {
1165 let conversation = self.conversation_text();
1166 assert!(
1167 !conversation.contains(needle),
1168 "conversation should not contain {needle:?}:
1169{conversation}"
1170 );
1171 }
1172
1173 pub fn assert_viewport<S: AsRef<str>>(&mut self, expected: &[S]) {
1175 assert_buffer_eq(&self.viewport(), expected);
1176 }
1177
1178 pub fn assert_history<S: AsRef<str>>(&mut self, expected: &[S]) {
1180 assert_buffer_eq(&self.history(), expected);
1181 }
1182
1183 pub fn assert_conversation<S: AsRef<str>>(&mut self, expected: &[S]) {
1185 assert_buffer_eq(&self.conversation(), expected);
1186 }
1187}
1188
1189pub struct TestUiBuilder {
1192 width: u16,
1193 height: u16,
1194 working_dir: Option<PathBuf>,
1195 capabilities: AetherCapabilities,
1196 prompt_capabilities: acp::PromptCapabilities,
1197 config_options: Vec<acp::SessionConfigOption>,
1198 auth_methods: Vec<acp::AuthMethod>,
1199 session_capabilities: Option<acp::SessionCapabilities>,
1200 settings: UiSettings,
1201 workspace_status: Option<WorkspaceStatus>,
1202 git: FakeGit,
1203 opened_urls: Arc<Mutex<Vec<String>>>,
1204}
1205
1206impl Default for TestUiBuilder {
1207 fn default() -> Self {
1208 Self {
1209 width: 40,
1210 height: 15,
1211 working_dir: None,
1212 capabilities: AetherCapabilities::default(),
1213 prompt_capabilities: acp::PromptCapabilities::new(),
1214 config_options: Vec::new(),
1215 auth_methods: Vec::new(),
1216 session_capabilities: None,
1217 settings: UiSettings::default(),
1218 workspace_status: None,
1219 git: FakeGit::default(),
1220 opened_urls: Arc::new(Mutex::new(Vec::new())),
1221 }
1222 }
1223}
1224
1225impl TestUiBuilder {
1226 pub fn new() -> Self {
1227 Self::default()
1228 }
1229
1230 pub fn dimensions(mut self, width: u16, height: u16) -> Self {
1231 self.width = width;
1232 self.height = height;
1233 self
1234 }
1235
1236 pub fn working_dir(mut self, working_dir: impl Into<PathBuf>) -> Self {
1237 self.working_dir = Some(working_dir.into());
1238 self
1239 }
1240
1241 pub fn prompt_capabilities(mut self, capabilities: acp::PromptCapabilities) -> Self {
1242 self.prompt_capabilities = capabilities;
1243 self
1244 }
1245
1246 pub fn config_options(mut self, options: Vec<acp::SessionConfigOption>) -> Self {
1247 self.config_options = options;
1248 self
1249 }
1250
1251 pub fn auth_methods(mut self, methods: Vec<acp::AuthMethod>) -> Self {
1252 self.auth_methods = methods;
1253 self
1254 }
1255
1256 pub fn settings(mut self, settings: UiSettings) -> Self {
1257 self.settings = settings;
1258 self
1259 }
1260
1261 pub fn workspace_status(mut self, workspace_status: WorkspaceStatus) -> Self {
1262 self.workspace_status = Some(workspace_status);
1263 self
1264 }
1265
1266 pub fn git(mut self, git: FakeGit) -> Self {
1267 self.git = git;
1268 self
1269 }
1270
1271 pub fn session_capabilities(mut self, capabilities: acp::SessionCapabilities) -> Self {
1274 self.session_capabilities = Some(capabilities);
1275 self
1276 }
1277
1278 pub fn prompt_search(mut self) -> Self {
1279 self.capabilities.prompt_search = true;
1280 self
1281 }
1282
1283 pub fn session_preview(mut self) -> Self {
1284 self.capabilities.session_preview = true;
1285 self
1286 }
1287
1288 pub fn workspace_move(mut self) -> Self {
1289 self.capabilities.workspace_move = true;
1290 self
1291 }
1292
1293 pub fn build(self) -> TestUi {
1295 self.finish()
1296 }
1297
1298 fn finish(self) -> TestUi {
1299 let app = App::new(self.app_config());
1300 TestUi {
1301 app,
1302 renderer: Renderer::new(),
1303 terminal: test_terminal(TestBackend::new(self.width, self.height)),
1304 executor: FakeExecutor::with_git(self.git),
1305 opened_urls: self.opened_urls,
1306 }
1307 }
1308
1309 fn app_config(&self) -> AppConfig {
1310 let session_capabilities = self
1311 .session_capabilities
1312 .clone()
1313 .unwrap_or_else(|| acp::SessionCapabilities::new().meta(Some(self.capabilities.clone().to_meta())));
1314 AppConfig {
1315 session_id: SessionId::new("test-session"),
1316 agent_name: "aether".to_string(),
1317 prompt_capabilities: self.prompt_capabilities.clone(),
1318 session_capabilities,
1319 config_options: self.config_options.clone(),
1320 auth_methods: self.auth_methods.clone(),
1321 workspace_status: self
1322 .workspace_status
1323 .clone()
1324 .unwrap_or_else(|| WorkspaceStatus::new("~/code/demo", Some("main".to_string()))),
1325 working_dir: self.working_dir.clone().unwrap_or_else(|| PathBuf::from(".")),
1326 settings: self.settings.clone(),
1327 browser_opener: {
1328 let opened = self.opened_urls.clone();
1329 Arc::new(move |url: &str| {
1330 opened.lock().unwrap().push(url.to_string());
1331 Ok(())
1332 }) as BrowserOpener
1333 },
1334 clipboard_writer: Arc::new(|_| Ok(())),
1335 }
1336 }
1337}
1338
1339fn test_terminal<B: Backend>(backend: B) -> Terminal<B>
1342where
1343 B::Error: std::fmt::Debug,
1344{
1345 let height = backend.size().unwrap().height;
1346 Terminal::with_options(backend, TerminalOptions { viewport: Viewport::Inline(inline_viewport_height(height)) })
1347 .unwrap()
1348}
1349
1350fn viewport_buffer<B>(terminal: &mut Terminal<B>) -> Buffer
1353where
1354 B: Backend + BuffersReader,
1355{
1356 let area = terminal.get_frame().area();
1357 let screen = terminal.backend().screen();
1358 let mut viewport = Buffer::empty(Rect::new(0, 0, area.width, area.height));
1359 for y in 0..area.height {
1360 for x in 0..area.width {
1361 viewport[(x, y)] = screen[(area.x + x, area.y + y)].clone();
1362 }
1363 }
1364 viewport
1365}
1366
1367fn history_buffer<B>(terminal: &mut Terminal<B>) -> Buffer
1369where
1370 B: Backend + BuffersReader,
1371{
1372 let viewport_area = terminal.get_frame().area();
1373 let screen = terminal.backend().screen();
1374 let scrollback = terminal.backend().scrollback();
1375 let history_height = scrollback.area.height.saturating_add(viewport_area.top());
1376 let mut history = Buffer::empty(Rect::new(0, 0, screen.area.width, history_height));
1377 for y in 0..scrollback.area.height {
1378 for x in 0..scrollback.area.width {
1379 history[(x, y)] = scrollback[(x, y)].clone();
1380 }
1381 }
1382 for y in 0..viewport_area.top() {
1383 for x in 0..screen.area.width {
1384 history[(x, scrollback.area.height + y)] = screen[(x, y)].clone();
1385 }
1386 }
1387 history
1388}
1389
1390fn conversation_buffer<B>(terminal: &mut Terminal<B>) -> Buffer
1391where
1392 B: Backend + BuffersReader,
1393{
1394 let history = history_buffer(terminal);
1395 let viewport = viewport_buffer(terminal);
1396 let mut conversation =
1397 Buffer::empty(Rect::new(0, 0, viewport.area.width, history.area.height.saturating_add(viewport.area.height)));
1398 for y in 0..history.area.height {
1399 for x in 0..history.area.width {
1400 conversation[(x, y)] = history[(x, y)].clone();
1401 }
1402 }
1403 for y in 0..viewport.area.height {
1404 for x in 0..viewport.area.width {
1405 conversation[(x, history.area.height + y)] = viewport[(x, y)].clone();
1406 }
1407 }
1408 conversation
1409}
1410
1411pub fn has_cell(buffer: &Buffer, symbol: &str, predicate: impl Fn(&Cell) -> bool) -> bool {
1413 for y in buffer.area.top()..buffer.area.bottom() {
1414 for x in buffer.area.left()..buffer.area.right() {
1415 if let Some(cell) = buffer.cell((x, y))
1416 && cell.symbol() == symbol
1417 && predicate(cell)
1418 {
1419 return true;
1420 }
1421 }
1422 }
1423 false
1424}
1425
1426pub fn line_text(line: &ratatui::text::Line<'_>) -> String {
1427 line.spans.iter().map(|span| span.content.as_ref()).collect()
1428}
1429
1430pub fn rows_with_background(buffer: &Buffer, background: ratatui::style::Color) -> usize {
1432 (buffer.area.top()..buffer.area.bottom())
1433 .filter(|&y| {
1434 (buffer.area.left()..buffer.area.right())
1435 .any(|x| buffer.cell((x, y)).is_some_and(|cell| cell.bg == background))
1436 })
1437 .count()
1438}
1439
1440pub fn row_containing(buffer: &Buffer, needle: &str) -> Option<u16> {
1441 (buffer.area.top()..buffer.area.bottom()).find(|&y| {
1442 let row = (buffer.area.left()..buffer.area.right())
1443 .map(|x| buffer.cell((x, y)).map_or(" ", Cell::symbol))
1444 .collect::<String>();
1445 row.contains(needle)
1446 })
1447}
1448
1449pub fn buffer_text(buffer: &Buffer) -> String {
1450 let mut out = String::new();
1451 for y in buffer.area.top()..buffer.area.bottom() {
1452 for x in buffer.area.left()..buffer.area.right() {
1453 out.push_str(buffer.cell((x, y)).map_or(" ", Cell::symbol));
1454 }
1455 out.push('\n');
1456 }
1457 out
1458}
1459
1460pub fn assert_buffer_eq<S: AsRef<str>>(buffer: &Buffer, expected: &[S]) {
1464 let actual_lines: Vec<String> =
1465 (buffer.area.top()..buffer.area.bottom()).map(|y| row_text(buffer, y).trim_end().to_string()).collect();
1466 for index in 0..actual_lines.len().max(expected.len()) {
1467 let actual_line = actual_lines.get(index).map_or("", String::as_str);
1468 let expected_line = expected.get(index).map_or("", AsRef::as_ref).trim_end();
1469 assert_eq!(
1470 actual_line,
1471 expected_line,
1472 "line {index} mismatch:\n expected: {expected_line:?}\n actual: {actual_line:?}\n\nfull buffer:\n{}",
1473 actual_lines.join("\n")
1474 );
1475 }
1476}
1477
1478pub fn row_text(buffer: &Buffer, y: u16) -> String {
1479 (buffer.area.left()..buffer.area.right()).map(|x| buffer.cell((x, y)).map_or(" ", Cell::symbol)).collect()
1480}
1481
1482#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1484pub enum StreamContent {
1485 Prose,
1488 CodeBlock,
1492 Thought,
1495}
1496
1497const SEED_PROSE: &str = "\
1498Examining the request. The module guards its invariants behind a shared handle,
1499so the fix has to land on the writer side rather than at each call site. I will
1500rework the boundary so retries cannot observe a torn update, then cover the
1501regression with a test that fails on the current code.
1502
1503";
1504
1505const SEED_CODE_BLOCK: &str = "\
1506```rust
1507fn reconcile(state: &mut State, incoming: Vec<Delta>) -> Outcome {
1508 let mut applied = Vec::with_capacity(incoming.len());
1509 for delta in incoming {
1510 if !state.accepts(&delta) {
1511 continue;
1512 }
1513 state.apply(&delta);
1514 applied.push(delta);
1515 }
1516 state.commit(applied)
1517}
1518```
1519
1520";
1521
1522const SEED_CLOSING: &str = "\
1523Done — the writer now retries atomically and the regression test covers the
1524torn window.
1525
1526";
1527
1528const SEED_DIFF_BEFORE: &str = "\
1529fn reconcile(state: &mut State, incoming: Vec<Delta>) -> Outcome {
1530 let mut applied = Vec::new();
1531 for delta in incoming {
1532 state.apply(&delta);
1533 }
1534 state.commit(Vec::new())
1535}
1536";
1537
1538const SEED_DIFF_AFTER: &str = "\
1539fn reconcile(state: &mut State, incoming: Vec<Delta>) -> Outcome {
1540 let mut applied = Vec::with_capacity(incoming.len());
1541 for delta in incoming {
1542 state.apply(&delta);
1543 applied.push(delta);
1544 }
1545 state.commit(applied)
1546}
1547";
1548
1549pub fn session_update(update: acp::SessionUpdate) -> AcpEvent {
1550 AcpEvent::SessionUpdate { session_id: SessionId::new("test-session"), update: Box::new(update) }
1551}
1552
1553fn user_chunk(text: &str) -> AcpEvent {
1554 session_update(acp::SessionUpdate::UserMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
1555 acp::TextContent::new(text),
1556 ))))
1557}
1558
1559pub fn text_chunk(text: &str) -> AcpEvent {
1560 session_update(acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
1561 acp::TextContent::new(text),
1562 ))))
1563}
1564
1565pub fn thought_chunk(text: &str) -> AcpEvent {
1566 session_update(acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
1567 acp::TextContent::new(text),
1568 ))))
1569}
1570
1571fn seed_bash_tool(id: &str) -> AcpEvent {
1572 let mut tool_call = acp::ToolCall::new(id.to_string(), format!("Run {id}"));
1573 tool_call.meta = Some(seed_tool_meta("bash"));
1574 tool_call.raw_input = Some(json!({ "command": "cargo test --module writer" }));
1575 session_update(acp::SessionUpdate::ToolCall(tool_call))
1576}
1577
1578fn seed_edit_tool(id: &str, turn: usize) -> AcpEvent {
1579 session_update(acp::SessionUpdate::ToolCall(acp::ToolCall::new(
1580 id.to_string(),
1581 format!("Editing src/module_{turn}.rs"),
1582 )))
1583}
1584
1585fn seed_spawn_tool(id: &str) -> AcpEvent {
1586 let mut tool_call = acp::ToolCall::new(id.to_string(), format!("Spawning sub-agents ({id})"));
1587 tool_call.meta = Some(seed_tool_meta("spawn_subagent"));
1588 session_update(acp::SessionUpdate::ToolCall(tool_call))
1589}
1590
1591fn seed_tool_meta(tool_name: &str) -> acp::Meta {
1592 let mut meta = serde_json::Map::new();
1593 meta.insert(AETHER_TOOL_NAME_META_KEY.to_string(), json!(tool_name));
1594 meta
1595}
1596
1597pub fn tool_completed(id: &str) -> AcpEvent {
1598 session_update(acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
1599 id.to_string(),
1600 acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed),
1601 )))
1602}
1603
1604fn seed_tool_diff(id: &str, turn: usize) -> AcpEvent {
1605 let diff = acp::Diff::new(format!("src/module_{turn}.rs"), SEED_DIFF_AFTER).old_text(SEED_DIFF_BEFORE);
1606 session_update(acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
1607 id.to_string(),
1608 acp::ToolCallUpdateFields::new()
1609 .content(vec![acp::ToolCallContent::Diff(diff)])
1610 .status(acp::ToolCallStatus::Completed),
1611 )))
1612}
1613
1614fn seed_sub_agent(parent: &str, task: &str, agent: &str, event: SubAgentEvent) -> AcpEvent {
1615 AcpEvent::SubAgentProgress(SubAgentProgressParams {
1616 parent_tool_id: parent.to_string(),
1617 task_id: task.to_string(),
1618 agent_name: agent.to_string(),
1619 event,
1620 })
1621}
1622
1623fn prose_message(total_bytes: usize) -> String {
1624 let mut message = String::new();
1625 let mut sentence = 0;
1626 while message.len() < total_bytes {
1627 for _ in 0..4 {
1628 let _ =
1629 write!(message, "Sentence {sentence} carries ordinary words so wrapping and parsing do real work. ");
1630 sentence += 1;
1631 }
1632 message.push_str("\n\n");
1633 }
1634 message
1635}
1636
1637fn code_block_message(total_bytes: usize) -> String {
1638 let mut message = String::from("```rust\n");
1639 let mut line = 0;
1640 while message.len() < total_bytes {
1641 let _ = writeln!(message, "let value_{line} = state.reconcile(incoming[{line}]).expect(\"delta accepted\");");
1642 line += 1;
1643 }
1644 message.push_str("```\n");
1645 message
1646}
1647
1648fn thought_message(total_bytes: usize) -> String {
1649 let mut message = String::new();
1650 let mut step = 0;
1651 while message.len() < total_bytes {
1652 let _ = writeln!(message, "Considering step {step} of the plan before acting on it.");
1653 step += 1;
1654 }
1655 message
1656}
1657
1658pub fn chunk_message(message: &str, chunk_bytes: usize) -> Vec<String> {
1659 let mut chunks = Vec::new();
1660 let mut rest = message;
1661 while !rest.is_empty() {
1662 let mut end = rest.len().min(chunk_bytes);
1663 while !rest.is_char_boundary(end) {
1664 end -= 1;
1665 }
1666 chunks.push(rest[..end].to_string());
1667 rest = &rest[end..];
1668 }
1669 chunks
1670}
1671
1672#[cfg(test)]
1673mod tests {
1674 use super::*;
1675 use crate::command::TerminalCommand;
1676 use crate::git_review::{FileStatus, StageState};
1677
1678 #[test]
1679 fn fake_executor_preserves_command_order() {
1680 let mut executor = FakeExecutor::new();
1681 executor
1682 .record([Command::Filesystem(FilesystemCommand::ListThemes), Command::Terminal(TerminalCommand::RingBell)]);
1683
1684 assert!(matches!(executor.take_commands()[..], [Command::Filesystem(_), Command::Terminal(_)]));
1685 }
1686
1687 #[test]
1688 fn fake_filesystem_persists_files_and_settings_in_memory() {
1689 let mut filesystem = FakeFilesystem::new();
1690 let path = PathBuf::from("workspace/src/main.rs");
1691 filesystem.write_file(&path, "fn main() {}");
1692 filesystem.save_settings(UiSettings::default());
1693
1694 assert_eq!(filesystem.read_to_string(&path).as_deref(), Some("fn main() {}"));
1695 assert!(filesystem.contains(Path::new("workspace/src")));
1696 assert!(filesystem.settings().is_some());
1697 }
1698
1699 #[test]
1700 fn fake_git_models_staging_and_discarding_state() {
1701 let mut git = FakeGit::new("workspace");
1702 git.add_file("src/main.rs", "initial\n");
1703 assert_eq!(git.status("src/main.rs"), Some((FileStatus::Untracked, StageState::Unstaged)));
1704
1705 git.stage("src/main.rs");
1706 assert_eq!(git.status("src/main.rs"), Some((FileStatus::Untracked, StageState::Staged)));
1707 git.commit("initial").unwrap();
1708
1709 git.write_file("src/main.rs", "changed\n");
1710 assert_eq!(git.status("src/main.rs"), Some((FileStatus::Modified, StageState::Unstaged)));
1711 git.discard("src/main.rs");
1712 assert_eq!(git.file("src/main.rs").and_then(|file| file.contents), Some(b"initial\n".to_vec()));
1713 }
1714}