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