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