Skip to main content

wisp/
testing.rs

1//! Public testing harness for driving the application model without side effects.
2//!
3//! Prefer [`TestUi`] over reaching for [`App`], [`Renderer`], or a raw
4//! `Terminal<TestBackend>`: it owns all of them and routes input, ACP events,
5//! task settling, and drawing through the same seams the event loop uses.
6
7use crate::app::message::Message;
8use crate::app::{App, AppConfig};
9use crate::attachment::{AttachmentOutcome, PromptAttachment, build_attachments_with};
10use crate::command::{AgentCommand, Command, CommandResult, FilesystemCommand, GitCommand};
11use crate::file_index::{FileEntry, MAX_INDEXED_FILES, file_entries};
12use crate::git_review::{
13    DiffDocument, DiffScope, FileDiff, FileStatus, GitDiffError, GitDiffEvent, StageState, build_untracked_file_diff,
14};
15pub use crate::renderer::RenderStats;
16use crate::renderer::Renderer;
17use crate::session::platform::BrowserOpener;
18use crate::session::terminal::inline_viewport_height;
19use crate::session::workspace_status::WorkspaceStatus;
20use crate::settings::UiSettings;
21use crate::surfaces::composer::ComposerLayout;
22use acp_utils::AETHER_TOOL_NAME_META_KEY;
23use acp_utils::client::AcpEvent;
24use acp_utils::notifications::{
25    AetherCapabilities, SubAgentEvent, SubAgentProgressParams, SubAgentToolRequest, SubAgentToolResult,
26};
27use agent_client_protocol::schema::v1::{self as acp, SessionId};
28use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
29use ratatui::backend::{Backend, ClearType, TestBackend, WindowSize};
30use ratatui::buffer::{Buffer, Cell};
31use ratatui::layout::{Position, Rect, Size};
32use ratatui::{Terminal, TerminalOptions, Viewport};
33use serde_json::json;
34use std::collections::{BTreeMap, BTreeSet, VecDeque};
35use std::fmt::Write as _;
36use std::path::{Path, PathBuf};
37use std::sync::{Arc, Mutex};
38use std::time::{Duration, Instant};
39
40/// A deterministic command runner for integration tests.
41///
42/// Commands are recorded in submission order. Tests may enqueue completions for
43/// commands whose results are part of the scenario; commands without a queued
44/// completion are still recorded and produce no state change.
45pub struct FakeExecutor {
46    /// Commands not yet consumed by a test through `next_command`.
47    available: VecDeque<Command>,
48    /// Commands not yet completed by `settle_tasks`.
49    pending: VecDeque<Command>,
50    git: FakeGit,
51    filesystem: FakeFilesystem,
52}
53
54impl Default for FakeExecutor {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60impl FakeExecutor {
61    pub fn new() -> Self {
62        Self::with_git(FakeGit::default())
63    }
64
65    pub fn with_git(git: FakeGit) -> Self {
66        Self { available: VecDeque::new(), pending: VecDeque::new(), git, filesystem: FakeFilesystem::default() }
67    }
68
69    pub fn git(&self) -> &FakeGit {
70        &self.git
71    }
72
73    pub fn git_mut(&mut self) -> &mut FakeGit {
74        &mut self.git
75    }
76
77    pub fn filesystem(&self) -> &FakeFilesystem {
78        &self.filesystem
79    }
80
81    pub fn filesystem_mut(&mut self) -> &mut FakeFilesystem {
82        &mut self.filesystem
83    }
84
85    pub fn record(&mut self, commands: impl IntoIterator<Item = Command>) {
86        for command in commands {
87            self.available.push_back(command.clone());
88            self.pending.push_back(command);
89        }
90    }
91
92    fn complete(&mut self, command: Command) -> Option<CommandResult> {
93        match command {
94            Command::ResolveWorkspace { cwd } => Some(CommandResult::WorkspaceResolved {
95                status: WorkspaceStatus::new(cwd.display().to_string(), None),
96                cwd,
97            }),
98            Command::Git(command) => Some(CommandResult::GitDiff(self.git.apply(command))),
99            Command::Filesystem(FilesystemCommand::PrepareSubmission { attachments }) => {
100                Some(CommandResult::SubmissionPrepared(self.filesystem.build_attachments(&attachments)))
101            }
102            Command::Filesystem(FilesystemCommand::IndexFiles { request_id, root }) => {
103                Some(CommandResult::FilesIndexed { request_id, files: self.filesystem.index_files(&root) })
104            }
105            _ => None,
106        }
107    }
108
109    fn take_pending(&mut self) -> Vec<Command> {
110        self.pending.drain(..).collect()
111    }
112
113    fn clear_available(&mut self) {
114        self.available.clear();
115    }
116
117    pub fn take_commands(&mut self) -> Vec<Command> {
118        self.pending.clear();
119        self.available.drain(..).collect()
120    }
121}
122
123/// An in-memory filesystem used by command-oriented tests.
124#[derive(Clone, Default)]
125pub struct FakeFilesystem {
126    files: BTreeMap<PathBuf, Vec<u8>>,
127    directories: BTreeSet<PathBuf>,
128    settings: Option<UiSettings>,
129}
130
131impl FakeFilesystem {
132    pub fn new() -> Self {
133        Self::default()
134    }
135
136    pub fn create_dir(&mut self, path: impl Into<PathBuf>) {
137        self.directories.insert(path.into());
138    }
139
140    pub fn write_file(&mut self, path: impl Into<PathBuf>, contents: impl AsRef<[u8]>) {
141        let path = path.into();
142        if let Some(parent) = path.parent() {
143            self.directories.insert(parent.to_path_buf());
144        }
145        self.files.insert(path, contents.as_ref().to_vec());
146    }
147
148    pub fn remove_file(&mut self, path: &Path) -> bool {
149        self.files.remove(path).is_some()
150    }
151
152    pub fn read_file(&self, path: &Path) -> Option<&[u8]> {
153        self.files.get(path).map(Vec::as_slice)
154    }
155
156    pub fn read_to_string(&self, path: &Path) -> Option<String> {
157        self.read_file(path).and_then(|contents| String::from_utf8(contents.to_vec()).ok())
158    }
159
160    pub fn contains(&self, path: &Path) -> bool {
161        self.files.contains_key(path) || self.directories.contains(path)
162    }
163
164    pub fn files(&self) -> impl Iterator<Item = (&Path, &[u8])> {
165        self.files.iter().map(|(path, contents)| (path.as_path(), contents.as_slice()))
166    }
167
168    pub fn directories(&self) -> impl Iterator<Item = &Path> {
169        self.directories.iter().map(PathBuf::as_path)
170    }
171
172    pub fn save_settings(&mut self, settings: UiSettings) {
173        self.settings = Some(settings);
174    }
175
176    pub fn settings(&self) -> Option<&UiSettings> {
177        self.settings.as_ref()
178    }
179
180    /// The runtime's file index over the in-memory tree, minus gitignore
181    /// semantics, which need a real repository.
182    pub fn index_files(&self, root: &Path) -> Vec<FileEntry> {
183        let paths = self.files.keys().filter(|path| path.starts_with(root)).cloned();
184        file_entries(root, paths, MAX_INDEXED_FILES)
185    }
186
187    /// The runtime's attachment preparation over in-memory contents: the same
188    /// pure encoding as the real reader, with reads answered from this tree.
189    pub fn build_attachments(&self, attachments: &[PromptAttachment]) -> AttachmentOutcome {
190        build_attachments_with(attachments, |path, display_name| {
191            if self.directories.contains(path) {
192                return Err(format!("Failed to read {display_name}: is a directory"));
193            }
194            self.files.get(path).cloned().ok_or_else(|| format!("Failed to read {display_name}: file not found"))
195        })
196    }
197}
198
199/// A small stateful Git model. It keeps working-tree, index, and committed
200/// snapshots separate so staging and discarding have observable semantics.
201#[derive(Clone, Default)]
202pub struct FakeGit {
203    state: std::sync::Arc<std::sync::Mutex<FakeGitState>>,
204}
205
206#[derive(Default)]
207struct FakeGitState {
208    root: PathBuf,
209    files: BTreeMap<String, FakeGitFile>,
210    commits: Vec<String>,
211    is_repo: bool,
212    commit_error: Option<String>,
213}
214
215#[derive(Clone, Debug, PartialEq, Eq)]
216pub struct FakeGitFile {
217    pub path: String,
218    pub contents: Option<Vec<u8>>,
219    pub staged_contents: Option<Vec<u8>>,
220    pub committed_contents: Option<Vec<u8>>,
221}
222
223impl FakeGit {
224    pub fn new(root: impl Into<PathBuf>) -> Self {
225        let state = FakeGitState { root: root.into(), is_repo: true, ..FakeGitState::default() };
226        Self { state: std::sync::Arc::new(std::sync::Mutex::new(state)) }
227    }
228
229    pub fn not_a_repository(root: impl Into<PathBuf>) -> Self {
230        let state = FakeGitState { root: root.into(), ..FakeGitState::default() };
231        Self { state: std::sync::Arc::new(std::sync::Mutex::new(state)) }
232    }
233
234    pub fn fail_next_commit(&mut self, error: impl Into<String>) {
235        self.state.lock().unwrap().commit_error = Some(error.into());
236    }
237
238    pub fn root(&self) -> PathBuf {
239        self.state.lock().unwrap().root.clone()
240    }
241
242    pub fn add_file(&mut self, path: impl Into<String>, contents: impl AsRef<[u8]>) {
243        let path = path.into();
244        self.state.lock().unwrap().files.insert(
245            path.clone(),
246            FakeGitFile {
247                path,
248                contents: Some(contents.as_ref().to_vec()),
249                staged_contents: None,
250                committed_contents: None,
251            },
252        );
253    }
254
255    pub fn write_file(&mut self, path: impl Into<String>, contents: impl AsRef<[u8]>) {
256        let path = path.into();
257        let mut state = self.state.lock().unwrap();
258        let file = state.files.entry(path.clone()).or_insert_with(|| FakeGitFile {
259            path,
260            contents: None,
261            staged_contents: None,
262            committed_contents: None,
263        });
264        file.contents = Some(contents.as_ref().to_vec());
265    }
266
267    pub fn remove_file(&mut self, path: &str) {
268        if let Some(file) = self.state.lock().unwrap().files.get_mut(path) {
269            file.contents = None;
270        }
271    }
272
273    pub fn stage(&mut self, path: &str) -> bool {
274        let mut state = self.state.lock().unwrap();
275        let Some(file) = state.files.get_mut(path) else { return false };
276        file.staged_contents = file.contents.clone();
277        true
278    }
279
280    pub fn unstage(&mut self, path: &str) -> bool {
281        let mut state = self.state.lock().unwrap();
282        let Some(file) = state.files.get_mut(path) else { return false };
283        file.staged_contents = file.committed_contents.clone();
284        true
285    }
286
287    pub fn stage_all(&mut self) {
288        let mut state = self.state.lock().unwrap();
289        for file in state.files.values_mut() {
290            file.staged_contents = file.contents.clone();
291        }
292    }
293
294    pub fn unstage_all(&mut self) {
295        let mut state = self.state.lock().unwrap();
296        for file in state.files.values_mut() {
297            file.staged_contents = file.committed_contents.clone();
298        }
299    }
300
301    pub fn discard(&mut self, path: &str) -> bool {
302        let mut state = self.state.lock().unwrap();
303        let Some(file) = state.files.get_mut(path) else { return false };
304        file.contents = file.committed_contents.clone();
305        file.staged_contents = file.committed_contents.clone();
306        true
307    }
308
309    pub fn commit(&mut self, message: impl Into<String>) -> Result<(), String> {
310        let message = message.into();
311        let mut state = self.state.lock().unwrap();
312        if message.trim().is_empty() {
313            return Err("empty commit message".to_string());
314        }
315        if !state.files.values().any(|file| file.staged_contents != file.committed_contents) {
316            return Err("nothing to commit".to_string());
317        }
318        for file in state.files.values_mut() {
319            if file.staged_contents != file.committed_contents {
320                file.committed_contents = file.staged_contents.clone();
321            }
322        }
323        state.commits.push(message);
324        Ok(())
325    }
326
327    pub fn file(&self, path: &str) -> Option<FakeGitFile> {
328        self.state.lock().unwrap().files.get(path).cloned()
329    }
330
331    pub fn files(&self) -> Vec<FakeGitFile> {
332        self.state.lock().unwrap().files.values().cloned().collect()
333    }
334
335    pub fn commits(&self) -> Vec<String> {
336        self.state.lock().unwrap().commits.clone()
337    }
338
339    pub fn status(&self, path: &str) -> Option<(FileStatus, StageState)> {
340        self.state.lock().unwrap().files.get(path).and_then(status_of)
341    }
342
343    fn load_diff(&self, scope: DiffScope) -> Result<DiffDocument, GitDiffError> {
344        let state = self.state.lock().unwrap();
345        if !state.is_repo {
346            return Err(GitDiffError::NotARepository);
347        }
348
349        let mut files = Vec::new();
350        for file in state.files.values() {
351            let untracked = file.committed_contents.is_none() && file.staged_contents.is_none();
352            if untracked {
353                if scope.includes_untracked()
354                    && let Some(contents) = &file.contents
355                {
356                    files.push(build_untracked_file_diff(file.path.clone(), contents));
357                }
358                continue;
359            }
360
361            let (old, new) = match scope {
362                DiffScope::Staged => (&file.committed_contents, &file.staged_contents),
363                DiffScope::Unstaged => {
364                    let old =
365                        if file.staged_contents.is_some() { &file.staged_contents } else { &file.committed_contents };
366                    (old, &file.contents)
367                }
368                DiffScope::Both => (&file.committed_contents, &file.contents),
369            };
370            if old == new {
371                continue;
372            }
373
374            let staged = status_of(file).map_or(StageState::Unstaged, |(_, stage)| stage);
375            let binary = old.as_ref().is_some_and(|bytes| is_binary(bytes))
376                || new.as_ref().is_some_and(|bytes| is_binary(bytes));
377            if binary {
378                let status = match (old, new) {
379                    (None, Some(_)) => FileStatus::Added,
380                    (Some(_), None) => FileStatus::Deleted,
381                    _ => FileStatus::Modified,
382                };
383                files.push(FileDiff {
384                    old_path: (status != FileStatus::Added).then(|| file.path.clone()),
385                    path: file.path.clone(),
386                    status,
387                    staged,
388                    hunks: Vec::new(),
389                    binary: true,
390                });
391                continue;
392            }
393
394            let old_text = old.as_deref().map(bytes_to_text).transpose()?.unwrap_or_default();
395            let new_text = new.as_deref().map(bytes_to_text).transpose()?.unwrap_or_default();
396            let mut diff = FileDiff::from_texts(file.path.clone(), &old_text, &new_text);
397            diff.staged = staged;
398            files.push(diff);
399        }
400        files.sort_by(|left, right| left.path.cmp(&right.path));
401        Ok(DiffDocument { repo_root: state.root.clone(), files })
402    }
403
404    fn read_full_file(&self, path: &str) -> Result<String, GitDiffError> {
405        let state = self.state.lock().unwrap();
406        let Some(contents) = state.files.get(path).and_then(|file| {
407            file.contents.as_deref().or(file.staged_contents.as_deref()).or(file.committed_contents.as_deref())
408        }) else {
409            return Err(GitDiffError::CommandFailed { stderr: format!("Cannot read {path}: file not found") });
410        };
411        String::from_utf8(contents.to_vec())
412            .map_err(|error| GitDiffError::CommandFailed { stderr: format!("Cannot read {path}: {error}") })
413    }
414
415    fn apply(&mut self, command: GitCommand) -> GitDiffEvent {
416        match command {
417            GitCommand::Load { request_id, scope, .. } => {
418                GitDiffEvent::Loaded { request_id, result: self.load_diff(scope) }
419            }
420            GitCommand::StageFiles { request_id, paths, .. } => {
421                for path in paths {
422                    self.stage(&path);
423                }
424                GitDiffEvent::ActionFinished { request_id, result: Ok(()) }
425            }
426            GitCommand::UnstageFiles { request_id, paths, .. } => {
427                for path in paths {
428                    self.unstage(&path);
429                }
430                GitDiffEvent::ActionFinished { request_id, result: Ok(()) }
431            }
432            GitCommand::StageAll { request_id, .. } => {
433                self.stage_all();
434                GitDiffEvent::ActionFinished { request_id, result: Ok(()) }
435            }
436            GitCommand::UnstageAll { request_id, .. } => {
437                self.unstage_all();
438                GitDiffEvent::ActionFinished { request_id, result: Ok(()) }
439            }
440            GitCommand::Commit { request_id, message, .. } => {
441                let error = self.state.lock().unwrap().commit_error.take();
442                let result = error.map_or_else(
443                    || self.commit(message).map_err(|stderr| GitDiffError::CommandFailed { stderr }),
444                    |stderr| Err(GitDiffError::CommandFailed { stderr }),
445                );
446                GitDiffEvent::ActionFinished { request_id, result }
447            }
448            GitCommand::DiscardFile { request_id, path, status, .. } => {
449                if status == FileStatus::Untracked {
450                    self.state.lock().unwrap().files.remove(&path);
451                } else {
452                    self.discard(&path);
453                }
454                GitDiffEvent::ActionFinished { request_id, result: Ok(()) }
455            }
456            GitCommand::LoadFullFile { request_id, path, .. } => {
457                GitDiffEvent::FullFileLoaded { request_id, result: self.read_full_file(&path), path }
458            }
459        }
460    }
461}
462
463fn status_of(file: &FakeGitFile) -> Option<(FileStatus, StageState)> {
464    let staged_changed = file.staged_contents != file.committed_contents;
465    let working_changed = file.contents != file.staged_contents;
466    if !staged_changed && !working_changed {
467        return None;
468    }
469
470    if file.committed_contents.is_none() {
471        let stage = match (file.staged_contents.is_some(), working_changed) {
472            (true, true) => StageState::PartiallyStaged,
473            (true, false) => StageState::Staged,
474            (false, _) => StageState::Unstaged,
475        };
476        return Some((FileStatus::Untracked, stage));
477    }
478
479    let stage = match (staged_changed, working_changed) {
480        (true, true) => StageState::PartiallyStaged,
481        (true, false) => StageState::Staged,
482        (false, true) => StageState::Unstaged,
483        (false, false) => unreachable!("clean files returned above"),
484    };
485    let status = if file.contents.is_none() { FileStatus::Deleted } else { FileStatus::Modified };
486    Some((status, stage))
487}
488
489fn is_binary(bytes: &[u8]) -> bool {
490    bytes.iter().take(8192).any(|byte| *byte == 0) || std::str::from_utf8(bytes).is_err()
491}
492
493fn bytes_to_text(bytes: &[u8]) -> Result<String, GitDiffError> {
494    std::str::from_utf8(bytes)
495        .map(str::to_string)
496        .map_err(|error| GitDiffError::CommandFailed { stderr: error.to_string() })
497}
498
499/// Deterministic terminal wrapper used by focused golden tests.
500pub struct TestTerminal {
501    terminal: Terminal<TestBackend>,
502}
503
504impl TestTerminal {
505    pub fn new(width: u16, height: u16) -> Self {
506        Self { terminal: test_terminal(TestBackend::new(width, height)) }
507    }
508
509    pub fn terminal(&mut self) -> &mut Terminal<TestBackend> {
510        &mut self.terminal
511    }
512
513    pub fn resize(&mut self, width: u16, height: u16) {
514        self.terminal.backend_mut().resize(width, height);
515    }
516
517    pub fn viewport(&mut self) -> Buffer {
518        viewport_buffer(&mut self.terminal)
519    }
520
521    pub fn history(&mut self) -> Buffer {
522        history_buffer(&mut self.terminal)
523    }
524
525    pub fn conversation(&mut self) -> Buffer {
526        conversation_buffer(&mut self.terminal)
527    }
528}
529
530/// A backend that records terminal history operations without involving a real
531/// terminal. It is intentionally small: only operations with an observable
532/// presentation contract are recorded.
533#[derive(Debug, Clone, Copy, PartialEq, Eq)]
534pub enum BackendEvent {
535    ShowCursor,
536    Scroll,
537}
538
539#[derive(Debug)]
540pub struct RecordingBackend {
541    inner: TestBackend,
542    events: Vec<BackendEvent>,
543}
544
545impl RecordingBackend {
546    pub fn new(width: u16, height: u16) -> Self {
547        Self { inner: TestBackend::new(width, height), events: Vec::new() }
548    }
549
550    pub fn events(&self) -> &[BackendEvent] {
551        &self.events
552    }
553
554    pub fn clear_events(&mut self) {
555        self.events.clear();
556    }
557
558    pub fn resize(&mut self, width: u16, height: u16) {
559        self.inner.resize(width, height);
560    }
561
562    pub fn buffer(&self) -> &Buffer {
563        self.inner.buffer()
564    }
565
566    pub fn scrollback(&self) -> &Buffer {
567        self.inner.scrollback()
568    }
569}
570
571impl Backend for RecordingBackend {
572    type Error = std::convert::Infallible;
573
574    fn draw<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
575    where
576        I: Iterator<Item = (u16, u16, &'a Cell)>,
577    {
578        self.inner.draw(content)
579    }
580
581    fn append_lines(&mut self, lines: u16) -> Result<(), Self::Error> {
582        self.inner.append_lines(lines)
583    }
584
585    fn hide_cursor(&mut self) -> Result<(), Self::Error> {
586        self.inner.hide_cursor()
587    }
588
589    fn show_cursor(&mut self) -> Result<(), Self::Error> {
590        self.events.push(BackendEvent::ShowCursor);
591        self.inner.show_cursor()
592    }
593
594    fn get_cursor_position(&mut self) -> Result<Position, Self::Error> {
595        self.inner.get_cursor_position()
596    }
597
598    fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> Result<(), Self::Error> {
599        self.inner.set_cursor_position(position)
600    }
601
602    fn clear(&mut self) -> Result<(), Self::Error> {
603        self.inner.clear()
604    }
605
606    fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error> {
607        self.inner.clear_region(clear_type)
608    }
609
610    fn size(&self) -> Result<Size, Self::Error> {
611        self.inner.size()
612    }
613
614    fn window_size(&mut self) -> Result<WindowSize, Self::Error> {
615        self.inner.window_size()
616    }
617
618    fn flush(&mut self) -> Result<(), Self::Error> {
619        self.inner.flush()
620    }
621
622    fn scroll_region_up(&mut self, region: std::ops::Range<u16>, lines: u16) -> Result<(), Self::Error> {
623        self.events.push(BackendEvent::Scroll);
624        let region = if region == (0..1) { 0..self.inner.size().unwrap().height } else { region };
625        self.inner.scroll_region_up(region, lines)
626    }
627
628    fn scroll_region_down(&mut self, region: std::ops::Range<u16>, lines: u16) -> Result<(), Self::Error> {
629        self.events.push(BackendEvent::Scroll);
630        self.inner.scroll_region_down(region, lines)
631    }
632}
633
634/// Backend work since the last [`CountingBackend::take_stats`]: how much a
635/// frame pushed at the terminal. Because ratatui diffs buffers before flushing,
636/// `cells_drawn` counts only cells that actually changed — a frame that
637/// repaints a settled screen costs nothing.
638#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
639pub struct BackendStats {
640    pub draws: u64,
641    pub cells_drawn: u64,
642    pub scrolls: u64,
643}
644
645/// A [`TestBackend`] that counts what rendering pushes at it, for tests that
646/// bound rendering work instead of timing it.
647#[derive(Debug)]
648pub struct CountingBackend {
649    inner: TestBackend,
650    stats: BackendStats,
651}
652
653impl CountingBackend {
654    pub fn new(width: u16, height: u16) -> Self {
655        Self { inner: TestBackend::new(width, height), stats: BackendStats::default() }
656    }
657
658    pub fn take_stats(&mut self) -> BackendStats {
659        std::mem::take(&mut self.stats)
660    }
661
662    pub fn buffer(&self) -> &Buffer {
663        self.inner.buffer()
664    }
665
666    pub fn scrollback(&self) -> &Buffer {
667        self.inner.scrollback()
668    }
669}
670
671impl Backend for CountingBackend {
672    type Error = std::convert::Infallible;
673
674    fn draw<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
675    where
676        I: Iterator<Item = (u16, u16, &'a Cell)>,
677    {
678        self.stats.draws += 1;
679        let mut cells = 0u64;
680        let drawn = self.inner.draw(content.inspect(|_| cells += 1));
681        self.stats.cells_drawn += cells;
682        drawn
683    }
684
685    fn hide_cursor(&mut self) -> Result<(), Self::Error> {
686        self.inner.hide_cursor()
687    }
688
689    fn show_cursor(&mut self) -> Result<(), Self::Error> {
690        self.inner.show_cursor()
691    }
692
693    fn get_cursor_position(&mut self) -> Result<Position, Self::Error> {
694        self.inner.get_cursor_position()
695    }
696
697    fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> Result<(), Self::Error> {
698        self.inner.set_cursor_position(position)
699    }
700
701    fn clear(&mut self) -> Result<(), Self::Error> {
702        self.inner.clear()
703    }
704
705    fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error> {
706        self.inner.clear_region(clear_type)
707    }
708
709    fn size(&self) -> Result<Size, Self::Error> {
710        self.inner.size()
711    }
712
713    fn window_size(&mut self) -> Result<WindowSize, Self::Error> {
714        self.inner.window_size()
715    }
716
717    fn flush(&mut self) -> Result<(), Self::Error> {
718        self.inner.flush()
719    }
720
721    fn scroll_region_up(&mut self, region: std::ops::Range<u16>, lines: u16) -> Result<(), Self::Error> {
722        self.stats.scrolls += 1;
723        self.inner.scroll_region_up(region, lines)
724    }
725
726    fn scroll_region_down(&mut self, region: std::ops::Range<u16>, lines: u16) -> Result<(), Self::Error> {
727        self.stats.scrolls += 1;
728        self.inner.scroll_region_down(region, lines)
729    }
730}
731
732/// A whole UI scenario: the app, the renderer that owns its scrollback, the
733/// terminal it draws into, and the receiver for the commands the app sends.
734///
735/// Construct through [`TestUi::new`], [`TestUi::with_dimensions`], or
736/// [`TestUiBuilder`]. Input, ACP events, ticks, and task results are routed the
737/// same way the event loop routes them, and drawing happens against the same
738/// renderer instance every frame so committed scrollback survives.
739pub struct TestUi<B: Backend = TestBackend> {
740    app: App,
741    renderer: Renderer,
742    terminal: Terminal<B>,
743    executor: FakeExecutor,
744    opened_urls: Arc<Mutex<Vec<String>>>,
745}
746
747impl<B: Backend> TestUi<B>
748where
749    B::Error: std::fmt::Debug,
750{
751    /// Builds a UI around an arbitrary backend (e.g. a recording backend that
752    /// asserts on frame-level terminal commands). The app is the default
753    /// scenario; configure anything else through [`TestUiBuilder`].
754    pub fn with_backend(backend: B) -> Self {
755        let builder = TestUiBuilder::new();
756        let app = App::new(builder.app_config());
757        Self {
758            app,
759            renderer: Renderer::new(),
760            terminal: test_terminal(backend),
761            executor: FakeExecutor::new(),
762            opened_urls: builder.opened_urls.clone(),
763        }
764    }
765
766    /// The app under test, for the read-only queries assertions are built from.
767    /// Drive it through this harness rather than mutating it directly, so the
768    /// commands it emits still reach the fake runtime.
769    pub fn app(&self) -> &App {
770        &self.app
771    }
772
773    /// Delivers one message through the public application boundary and records
774    /// every command it emits in the fake runtime.
775    pub fn deliver(&mut self, message: Message) {
776        self.executor.record(self.app.update(message));
777    }
778
779    pub fn deliver_result(&mut self, result: CommandResult) {
780        self.deliver(Message::CommandFinished(result));
781    }
782
783    /// Commands emitted by the public application boundary, in order.
784    pub fn executor(&self) -> &FakeExecutor {
785        &self.executor
786    }
787
788    pub fn executor_mut(&mut self) -> &mut FakeExecutor {
789        &mut self.executor
790    }
791
792    pub fn take_commands(&mut self) -> Vec<Command> {
793        self.executor.take_commands()
794    }
795
796    pub fn next_command(&mut self) -> Option<Command> {
797        self.executor.available.pop_front()
798    }
799
800    pub fn next_agent_command(&mut self) -> Option<AgentCommand> {
801        while let Some(command) = self.next_command() {
802            if let Command::Agent(command) = command {
803                return Some(command);
804            }
805        }
806        None
807    }
808
809    pub fn backend(&self) -> &B {
810        self.terminal.backend()
811    }
812
813    pub fn backend_mut(&mut self) -> &mut B {
814        self.terminal.backend_mut()
815    }
816
817    pub fn viewport_area(&mut self) -> ratatui::layout::Rect {
818        self.terminal.get_frame().area()
819    }
820
821    pub fn viewport_height(&mut self) -> u16 {
822        self.viewport_area().height
823    }
824
825    /// Measures the composer at `width` with the active theme.
826    pub fn composer_layout(&mut self, width: u16) -> ComposerLayout {
827        let theme = self.app.theme().clone();
828        let composer = self.app.composer_mut();
829        composer.on_resize(width);
830        composer.layout(width, &theme)
831    }
832
833    /// Draws one frame, like the event loop does after every input batch.
834    pub fn draw(&mut self) {
835        self.renderer.draw(&mut self.terminal, &mut self.app).unwrap();
836    }
837
838    pub fn render_stats(&mut self) -> RenderStats {
839        self.renderer.take_stats()
840    }
841
842    /// URLs elicitation prompts asked to open, in order. The harness records
843    /// them instead of spawning a browser, so no test touches the host.
844    pub fn opened_urls(&self) -> Vec<String> {
845        self.opened_urls.lock().unwrap().clone()
846    }
847
848    /// Seeds a deterministic long conversation: `turns` completed turns of
849    /// user messages, thoughts, markdown prose with fenced code, bash and edit
850    /// tool calls with diffs, and a sub-agent tree every eighth turn — drawing
851    /// after each turn so scrollback commits exactly like a live session.
852    pub fn seed_long_history(&mut self, turns: usize) {
853        for turn in 0..turns {
854            self.acp_event(user_chunk(&format!(
855                "Turn {turn}: reconcile the writer path in module_{turn} and add a regression test."
856            )));
857            self.acp_event(thought_chunk(&format!(
858                "Reading module_{turn} to find the torn-update window before touching any call site."
859            )));
860            self.acp_event(text_chunk(SEED_PROSE));
861            self.acp_event(text_chunk(SEED_CODE_BLOCK));
862            let bash = format!("seed-bash-{turn}");
863            self.acp_event(seed_bash_tool(&bash));
864            self.acp_event(tool_completed(&bash));
865            let edit = format!("seed-edit-{turn}");
866            self.acp_event(seed_edit_tool(&edit, turn));
867            self.acp_event(seed_tool_diff(&edit, turn));
868            if turn % 8 == 0 {
869                self.seed_sub_agent_tree(turn);
870            }
871            self.acp_event(text_chunk(SEED_CLOSING));
872            self.acp_event(AcpEvent::PromptDone(acp::StopReason::EndTurn));
873            self.draw();
874        }
875    }
876
877    /// One spawn tool whose sub-agents run and finish, leaving a sealed tree.
878    fn seed_sub_agent_tree(&mut self, turn: usize) {
879        let parent = format!("seed-spawn-{turn}");
880        self.acp_event(seed_spawn_tool(&parent));
881        self.acp_event(tool_completed(&parent));
882        for agent in ["explorer", "fixer"] {
883            let task = format!("{parent}-{agent}");
884            self.acp_event(seed_sub_agent(
885                &parent,
886                &task,
887                agent,
888                SubAgentEvent::ToolCall {
889                    request: SubAgentToolRequest {
890                        id: format!("{task}-grep"),
891                        name: "grep".to_string(),
892                        arguments: r#"{"pattern":"torn update"}"#.to_string(),
893                    },
894                },
895            ));
896            self.acp_event(seed_sub_agent(
897                &parent,
898                &task,
899                agent,
900                SubAgentEvent::ToolResult {
901                    result: SubAgentToolResult {
902                        id: format!("{task}-grep"),
903                        name: "grep".to_string(),
904                        result_meta: None,
905                    },
906                },
907            ));
908            self.acp_event(seed_sub_agent(&parent, &task, agent, SubAgentEvent::Done));
909        }
910    }
911
912    /// Streams one assistant message of `total_bytes` in `chunk_bytes` chunks,
913    /// drawing after every chunk the way the event loop draws after every
914    /// wakeup. The message stays one open item while it streams.
915    pub fn stream_message(&mut self, content: StreamContent, total_bytes: usize, chunk_bytes: usize) {
916        let thought = matches!(content, StreamContent::Thought);
917        let message = match content {
918            StreamContent::Prose => prose_message(total_bytes),
919            StreamContent::CodeBlock => code_block_message(total_bytes),
920            StreamContent::Thought => thought_message(total_bytes),
921        };
922        for chunk in chunk_message(&message, chunk_bytes.max(1)) {
923            if thought {
924                self.acp_event(thought_chunk(&chunk));
925            } else {
926                self.acp_event(text_chunk(&chunk));
927            }
928            self.draw();
929        }
930    }
931
932    /// Finishes the in-flight turn and advances synthetic time past every
933    /// grace period, leaving a session that owes the event loop nothing.
934    pub fn settle(&mut self) {
935        self.acp_event(AcpEvent::PromptDone(acp::StopReason::EndTurn));
936        let mut now = Instant::now();
937        // The plan tracker's grace period (3s) is the longest deadline a
938        // settled session can still be waiting on.
939        for _ in 0..12 {
940            self.tick(now);
941            now += Duration::from_millis(500);
942            if !self.app().wants_tick() {
943                break;
944            }
945        }
946        assert!(!self.app().wants_tick(), "a settled session must stop driving the tick loop");
947        self.draw();
948    }
949
950    /// Routes a terminal event (key, paste, mouse, resize) the way the event
951    /// loop does.
952    pub fn terminal_event(&mut self, event: Event) {
953        self.deliver(Message::Terminal(event));
954    }
955
956    pub fn key(&mut self, key: KeyEvent) {
957        self.deliver(Message::Terminal(Event::Key(key)));
958    }
959
960    pub fn type_text(&mut self, text: &str) {
961        for character in text.chars() {
962            self.key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE));
963        }
964    }
965
966    /// Types `text` and submits it with Enter.
967    pub fn submit(&mut self, text: &str) {
968        self.type_text(text);
969        self.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
970    }
971
972    pub fn paste(&mut self, text: &str) {
973        self.deliver(Message::Terminal(Event::Paste(text.to_string())));
974    }
975
976    pub fn acp_event(&mut self, event: AcpEvent) {
977        self.deliver(Message::Agent(Box::new(event)));
978    }
979
980    pub fn tick(&mut self, now: Instant) {
981        self.deliver(Message::Tick(now));
982    }
983
984    /// Completes the commands configured on the fake executor without touching
985    /// the real filesystem, Git repository, terminal, or ACP connection.
986    pub fn settle_tasks(&mut self) {
987        self.executor.clear_available();
988        let mut initial_batch = true;
989        loop {
990            let pending = self.executor.take_pending();
991            if pending.is_empty() {
992                return;
993            }
994            if initial_batch {
995                for command in &pending {
996                    if matches!(command, Command::Agent(_)) {
997                        self.executor.available.push_back(command.clone());
998                    }
999                }
1000            }
1001            for command in pending {
1002                if let Some(result) = self.executor.complete(command) {
1003                    self.deliver(Message::CommandFinished(result));
1004                }
1005            }
1006            initial_batch = false;
1007        }
1008    }
1009}
1010
1011impl TestUi<TestBackend> {
1012    /// The default scenario: 40x15 terminal, plain app, recording prompt handle.
1013    pub fn new() -> Self {
1014        Self::with_dimensions(40, 15)
1015    }
1016
1017    pub fn with_dimensions(width: u16, height: u16) -> Self {
1018        TestUiBuilder::new().dimensions(width, height).build()
1019    }
1020
1021    /// Resizes the backing terminal. The next [`Self::draw`] re-measures the
1022    /// inline viewport the way `Renderer::draw` does in the event loop.
1023    pub fn resize(&mut self, width: u16, height: u16) {
1024        self.terminal.backend_mut().resize(width, height);
1025    }
1026}
1027
1028impl Default for TestUi<TestBackend> {
1029    fn default() -> Self {
1030        Self::new()
1031    }
1032}
1033
1034/// A backend whose screen and scrollback buffers can be read back, so a
1035/// [`TestUi`] built on it can expose viewport/history/conversation text.
1036pub trait BuffersReader {
1037    fn screen(&self) -> &Buffer;
1038    fn scrollback(&self) -> &Buffer;
1039}
1040
1041impl BuffersReader for TestBackend {
1042    fn screen(&self) -> &Buffer {
1043        self.buffer()
1044    }
1045
1046    fn scrollback(&self) -> &Buffer {
1047        self.scrollback()
1048    }
1049}
1050
1051impl BuffersReader for CountingBackend {
1052    fn screen(&self) -> &Buffer {
1053        self.buffer()
1054    }
1055
1056    fn scrollback(&self) -> &Buffer {
1057        self.scrollback()
1058    }
1059}
1060
1061impl BuffersReader for RecordingBackend {
1062    fn screen(&self) -> &Buffer {
1063        self.buffer()
1064    }
1065
1066    fn scrollback(&self) -> &Buffer {
1067        self.scrollback()
1068    }
1069}
1070
1071impl<B> TestUi<B>
1072where
1073    B: Backend + BuffersReader,
1074    B::Error: std::fmt::Debug,
1075{
1076    /// What the inline viewport currently shows: the composer, status line,
1077    /// and the live tail of the conversation. Draws a frame first, so asserting
1078    /// right after an input never reads a stale screen.
1079    pub fn viewport(&mut self) -> Buffer {
1080        self.draw();
1081        viewport_buffer(&mut self.terminal)
1082    }
1083
1084    /// The terminal's own native scrollback containing committed conversation
1085    /// rows, after drawing a frame.
1086    pub fn history(&mut self) -> Buffer {
1087        self.draw();
1088        history_buffer(&mut self.terminal)
1089    }
1090
1091    /// [`Self::history`] stacked on [`Self::viewport`]: everything the
1092    /// conversation has shown, oldest at the top, after drawing a frame.
1093    pub fn conversation(&mut self) -> Buffer {
1094        self.draw();
1095        conversation_buffer(&mut self.terminal)
1096    }
1097
1098    pub fn viewport_text(&mut self) -> String {
1099        buffer_text(&self.viewport())
1100    }
1101
1102    pub fn history_text(&mut self) -> String {
1103        buffer_text(&self.history())
1104    }
1105
1106    pub fn conversation_text(&mut self) -> String {
1107        buffer_text(&self.conversation())
1108    }
1109
1110    /// Row (within the viewport buffer) of the first line containing `needle`.
1111    pub fn viewport_row(&mut self, needle: &str) -> Option<u16> {
1112        row_containing(&self.viewport(), needle)
1113    }
1114
1115    pub fn assert_viewport_contains(&mut self, needle: &str) {
1116        let viewport = self.viewport_text();
1117        assert!(
1118            viewport.contains(needle),
1119            "viewport should contain {needle:?}:
1120{viewport}"
1121        );
1122    }
1123
1124    pub fn assert_viewport_not_contains(&mut self, needle: &str) {
1125        let viewport = self.viewport_text();
1126        assert!(
1127            !viewport.contains(needle),
1128            "viewport should not contain {needle:?}:
1129{viewport}"
1130        );
1131    }
1132
1133    pub fn assert_history_contains(&mut self, needle: &str) {
1134        let history = self.history_text();
1135        assert!(
1136            history.contains(needle),
1137            "history should contain {needle:?}:
1138{history}"
1139        );
1140    }
1141
1142    pub fn assert_history_not_contains(&mut self, needle: &str) {
1143        let history = self.history_text();
1144        assert!(
1145            !history.contains(needle),
1146            "history should not contain {needle:?}:
1147{history}"
1148        );
1149    }
1150
1151    pub fn assert_conversation_contains(&mut self, needle: &str) {
1152        let conversation = self.conversation_text();
1153        assert!(
1154            conversation.contains(needle),
1155            "conversation should contain {needle:?}:
1156{conversation}"
1157        );
1158    }
1159
1160    pub fn assert_conversation_not_contains(&mut self, needle: &str) {
1161        let conversation = self.conversation_text();
1162        assert!(
1163            !conversation.contains(needle),
1164            "conversation should not contain {needle:?}:
1165{conversation}"
1166        );
1167    }
1168
1169    /// Asserts the viewport's visible text matches `expected` row-by-row.
1170    pub fn assert_viewport<S: AsRef<str>>(&mut self, expected: &[S]) {
1171        assert_buffer_eq(&self.viewport(), expected);
1172    }
1173
1174    /// Asserts the committed history's visible text matches `expected` row-by-row.
1175    pub fn assert_history<S: AsRef<str>>(&mut self, expected: &[S]) {
1176        assert_buffer_eq(&self.history(), expected);
1177    }
1178
1179    /// Asserts the stitched conversation's visible text matches `expected` row-by-row.
1180    pub fn assert_conversation<S: AsRef<str>>(&mut self, expected: &[S]) {
1181        assert_buffer_eq(&self.conversation(), expected);
1182    }
1183}
1184
1185/// Builds a [`TestUi`]: the terminal dimensions plus every app-scenario option
1186/// a test cares about. Defaults match a plain `make_app()`-style scenario.
1187pub struct TestUiBuilder {
1188    width: u16,
1189    height: u16,
1190    working_dir: Option<PathBuf>,
1191    capabilities: AetherCapabilities,
1192    prompt_capabilities: acp::PromptCapabilities,
1193    config_options: Vec<acp::SessionConfigOption>,
1194    auth_methods: Vec<acp::AuthMethod>,
1195    session_capabilities: Option<acp::SessionCapabilities>,
1196    settings: UiSettings,
1197    workspace_status: Option<WorkspaceStatus>,
1198    git: FakeGit,
1199    opened_urls: Arc<Mutex<Vec<String>>>,
1200}
1201
1202impl Default for TestUiBuilder {
1203    fn default() -> Self {
1204        Self {
1205            width: 40,
1206            height: 15,
1207            working_dir: None,
1208            capabilities: AetherCapabilities::default(),
1209            prompt_capabilities: acp::PromptCapabilities::new(),
1210            config_options: Vec::new(),
1211            auth_methods: Vec::new(),
1212            session_capabilities: None,
1213            settings: UiSettings::default(),
1214            workspace_status: None,
1215            git: FakeGit::default(),
1216            opened_urls: Arc::new(Mutex::new(Vec::new())),
1217        }
1218    }
1219}
1220
1221impl TestUiBuilder {
1222    pub fn new() -> Self {
1223        Self::default()
1224    }
1225
1226    pub fn dimensions(mut self, width: u16, height: u16) -> Self {
1227        self.width = width;
1228        self.height = height;
1229        self
1230    }
1231
1232    pub fn working_dir(mut self, working_dir: impl Into<PathBuf>) -> Self {
1233        self.working_dir = Some(working_dir.into());
1234        self
1235    }
1236
1237    pub fn prompt_capabilities(mut self, capabilities: acp::PromptCapabilities) -> Self {
1238        self.prompt_capabilities = capabilities;
1239        self
1240    }
1241
1242    pub fn config_options(mut self, options: Vec<acp::SessionConfigOption>) -> Self {
1243        self.config_options = options;
1244        self
1245    }
1246
1247    pub fn auth_methods(mut self, methods: Vec<acp::AuthMethod>) -> Self {
1248        self.auth_methods = methods;
1249        self
1250    }
1251
1252    pub fn settings(mut self, settings: UiSettings) -> Self {
1253        self.settings = settings;
1254        self
1255    }
1256
1257    pub fn workspace_status(mut self, workspace_status: WorkspaceStatus) -> Self {
1258        self.workspace_status = Some(workspace_status);
1259        self
1260    }
1261
1262    pub fn git(mut self, git: FakeGit) -> Self {
1263        self.git = git;
1264        self
1265    }
1266
1267    /// Overrides the capabilities wholesale, for tests that care about metadata
1268    /// the individual toggles do not cover.
1269    pub fn session_capabilities(mut self, capabilities: acp::SessionCapabilities) -> Self {
1270        self.session_capabilities = Some(capabilities);
1271        self
1272    }
1273
1274    pub fn prompt_search(mut self) -> Self {
1275        self.capabilities.prompt_search = true;
1276        self
1277    }
1278
1279    pub fn session_preview(mut self) -> Self {
1280        self.capabilities.session_preview = true;
1281        self
1282    }
1283
1284    pub fn workspace_move(mut self) -> Self {
1285        self.capabilities.workspace_move = true;
1286        self
1287    }
1288
1289    /// Builds the whole UI scenario.
1290    pub fn build(self) -> TestUi {
1291        self.finish()
1292    }
1293
1294    fn finish(self) -> TestUi {
1295        let app = App::new(self.app_config());
1296        TestUi {
1297            app,
1298            renderer: Renderer::new(),
1299            terminal: test_terminal(TestBackend::new(self.width, self.height)),
1300            executor: FakeExecutor::with_git(self.git),
1301            opened_urls: self.opened_urls,
1302        }
1303    }
1304
1305    fn app_config(&self) -> AppConfig {
1306        let session_capabilities = self
1307            .session_capabilities
1308            .clone()
1309            .unwrap_or_else(|| acp::SessionCapabilities::new().meta(Some(self.capabilities.clone().to_meta())));
1310        AppConfig {
1311            session_id: SessionId::new("test-session"),
1312            agent_name: "aether".to_string(),
1313            prompt_capabilities: self.prompt_capabilities.clone(),
1314            session_capabilities,
1315            config_options: self.config_options.clone(),
1316            auth_methods: self.auth_methods.clone(),
1317            workspace_status: self
1318                .workspace_status
1319                .clone()
1320                .unwrap_or_else(|| WorkspaceStatus::new("~/code/demo", Some("main".to_string()))),
1321            working_dir: self.working_dir.clone().unwrap_or_else(|| PathBuf::from(".")),
1322            settings: self.settings.clone(),
1323            browser_opener: {
1324                let opened = self.opened_urls.clone();
1325                Arc::new(move |url: &str| {
1326                    opened.lock().unwrap().push(url.to_string());
1327                    Ok(())
1328                }) as BrowserOpener
1329            },
1330            clipboard_writer: Arc::new(|_| Ok(())),
1331        }
1332    }
1333}
1334
1335/// The terminal a scenario draws into: the inline viewport sized from the
1336/// backend, exactly as the real event loop enters it.
1337fn test_terminal<B: Backend>(backend: B) -> Terminal<B>
1338where
1339    B::Error: std::fmt::Debug,
1340{
1341    let height = backend.size().unwrap().height;
1342    Terminal::with_options(backend, TerminalOptions { viewport: Viewport::Inline(inline_viewport_height(height)) })
1343        .unwrap()
1344}
1345
1346/// What the inline viewport shows: `terminal.get_frame().area()` clipped out of
1347/// the backend's full screen buffer.
1348fn viewport_buffer<B>(terminal: &mut Terminal<B>) -> Buffer
1349where
1350    B: Backend + BuffersReader,
1351{
1352    let area = terminal.get_frame().area();
1353    let screen = terminal.backend().screen();
1354    let mut viewport = Buffer::empty(Rect::new(0, 0, area.width, area.height));
1355    for y in 0..area.height {
1356        for x in 0..area.width {
1357            viewport[(x, y)] = screen[(area.x + x, area.y + y)].clone();
1358        }
1359    }
1360    viewport
1361}
1362
1363/// Content Ratatui's `insert_before` committed above the inline viewport.
1364fn history_buffer<B>(terminal: &mut Terminal<B>) -> Buffer
1365where
1366    B: Backend + BuffersReader,
1367{
1368    let viewport_area = terminal.get_frame().area();
1369    let screen = terminal.backend().screen();
1370    let scrollback = terminal.backend().scrollback();
1371    let history_height = scrollback.area.height.saturating_add(viewport_area.top());
1372    let mut history = Buffer::empty(Rect::new(0, 0, screen.area.width, history_height));
1373    for y in 0..scrollback.area.height {
1374        for x in 0..scrollback.area.width {
1375            history[(x, y)] = scrollback[(x, y)].clone();
1376        }
1377    }
1378    for y in 0..viewport_area.top() {
1379        for x in 0..screen.area.width {
1380            history[(x, scrollback.area.height + y)] = screen[(x, y)].clone();
1381        }
1382    }
1383    history
1384}
1385
1386fn conversation_buffer<B>(terminal: &mut Terminal<B>) -> Buffer
1387where
1388    B: Backend + BuffersReader,
1389{
1390    let history = history_buffer(terminal);
1391    let viewport = viewport_buffer(terminal);
1392    let mut conversation =
1393        Buffer::empty(Rect::new(0, 0, viewport.area.width, history.area.height.saturating_add(viewport.area.height)));
1394    for y in 0..history.area.height {
1395        for x in 0..history.area.width {
1396            conversation[(x, y)] = history[(x, y)].clone();
1397        }
1398    }
1399    for y in 0..viewport.area.height {
1400        for x in 0..viewport.area.width {
1401            conversation[(x, history.area.height + y)] = viewport[(x, y)].clone();
1402        }
1403    }
1404    conversation
1405}
1406
1407/// Whether any cell drawn with `symbol` satisfies `predicate`.
1408pub fn has_cell(buffer: &Buffer, symbol: &str, predicate: impl Fn(&Cell) -> bool) -> bool {
1409    for y in buffer.area.top()..buffer.area.bottom() {
1410        for x in buffer.area.left()..buffer.area.right() {
1411            if let Some(cell) = buffer.cell((x, y))
1412                && cell.symbol() == symbol
1413                && predicate(cell)
1414            {
1415                return true;
1416            }
1417        }
1418    }
1419    false
1420}
1421
1422pub fn line_text(line: &ratatui::text::Line<'_>) -> String {
1423    line.spans.iter().map(|span| span.content.as_ref()).collect()
1424}
1425
1426/// How many rows contain at least one cell with `background`.
1427pub fn rows_with_background(buffer: &Buffer, background: ratatui::style::Color) -> usize {
1428    (buffer.area.top()..buffer.area.bottom())
1429        .filter(|&y| {
1430            (buffer.area.left()..buffer.area.right())
1431                .any(|x| buffer.cell((x, y)).is_some_and(|cell| cell.bg == background))
1432        })
1433        .count()
1434}
1435
1436pub fn row_containing(buffer: &Buffer, needle: &str) -> Option<u16> {
1437    (buffer.area.top()..buffer.area.bottom()).find(|&y| {
1438        let row = (buffer.area.left()..buffer.area.right())
1439            .map(|x| buffer.cell((x, y)).map_or(" ", Cell::symbol))
1440            .collect::<String>();
1441        row.contains(needle)
1442    })
1443}
1444
1445pub fn buffer_text(buffer: &Buffer) -> String {
1446    let mut out = String::new();
1447    for y in buffer.area.top()..buffer.area.bottom() {
1448        for x in buffer.area.left()..buffer.area.right() {
1449            out.push_str(buffer.cell((x, y)).map_or(" ", Cell::symbol));
1450        }
1451        out.push('\n');
1452    }
1453    out
1454}
1455
1456/// Asserts `buffer`'s visible text matches `expected` row-by-row after trimming
1457/// trailing spaces, panicking on the first mismatched line with the full buffer
1458/// dumped.
1459pub fn assert_buffer_eq<S: AsRef<str>>(buffer: &Buffer, expected: &[S]) {
1460    let actual_lines: Vec<String> =
1461        (buffer.area.top()..buffer.area.bottom()).map(|y| row_text(buffer, y).trim_end().to_string()).collect();
1462    for index in 0..actual_lines.len().max(expected.len()) {
1463        let actual_line = actual_lines.get(index).map_or("", String::as_str);
1464        let expected_line = expected.get(index).map_or("", AsRef::as_ref).trim_end();
1465        assert_eq!(
1466            actual_line,
1467            expected_line,
1468            "line {index} mismatch:\n  expected: {expected_line:?}\n  actual:   {actual_line:?}\n\nfull buffer:\n{}",
1469            actual_lines.join("\n")
1470        );
1471    }
1472}
1473
1474pub fn row_text(buffer: &Buffer, y: u16) -> String {
1475    (buffer.area.left()..buffer.area.right()).map(|x| buffer.cell((x, y)).map_or(" ", Cell::symbol)).collect()
1476}
1477
1478/// The shape of message [`TestUi::stream_message`] streams.
1479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1480pub enum StreamContent {
1481    /// Flowing paragraphs, so finalization sealing runs as blank lines land and
1482    /// the open item stays small.
1483    Prose,
1484    /// One fenced Rust code block. No blank line outside the fence can finalize
1485    /// it while it streams, so the open item grows to the whole message — the
1486    /// shape that keeps re-rendering everything received so far.
1487    CodeBlock,
1488    /// A thinking stream: line-per-sentence thought chunks, which drive the
1489    /// progress band's activity rather than appending to the conversation.
1490    Thought,
1491}
1492
1493const SEED_PROSE: &str = "\
1494Examining the request. The module guards its invariants behind a shared handle,
1495so the fix has to land on the writer side rather than at each call site. I will
1496rework the boundary so retries cannot observe a torn update, then cover the
1497regression with a test that fails on the current code.
1498
1499";
1500
1501const SEED_CODE_BLOCK: &str = "\
1502```rust
1503fn reconcile(state: &mut State, incoming: Vec<Delta>) -> Outcome {
1504    let mut applied = Vec::with_capacity(incoming.len());
1505    for delta in incoming {
1506        if !state.accepts(&delta) {
1507            continue;
1508        }
1509        state.apply(&delta);
1510        applied.push(delta);
1511    }
1512    state.commit(applied)
1513}
1514```
1515
1516";
1517
1518const SEED_CLOSING: &str = "\
1519Done — the writer now retries atomically and the regression test covers the
1520torn window.
1521
1522";
1523
1524const SEED_DIFF_BEFORE: &str = "\
1525fn reconcile(state: &mut State, incoming: Vec<Delta>) -> Outcome {
1526    let mut applied = Vec::new();
1527    for delta in incoming {
1528        state.apply(&delta);
1529    }
1530    state.commit(Vec::new())
1531}
1532";
1533
1534const SEED_DIFF_AFTER: &str = "\
1535fn reconcile(state: &mut State, incoming: Vec<Delta>) -> Outcome {
1536    let mut applied = Vec::with_capacity(incoming.len());
1537    for delta in incoming {
1538        state.apply(&delta);
1539        applied.push(delta);
1540    }
1541    state.commit(applied)
1542}
1543";
1544
1545pub fn session_update(update: acp::SessionUpdate) -> AcpEvent {
1546    AcpEvent::SessionUpdate { session_id: SessionId::new("test-session"), update: Box::new(update) }
1547}
1548
1549fn user_chunk(text: &str) -> AcpEvent {
1550    session_update(acp::SessionUpdate::UserMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
1551        acp::TextContent::new(text),
1552    ))))
1553}
1554
1555pub fn text_chunk(text: &str) -> AcpEvent {
1556    session_update(acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
1557        acp::TextContent::new(text),
1558    ))))
1559}
1560
1561pub fn thought_chunk(text: &str) -> AcpEvent {
1562    session_update(acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
1563        acp::TextContent::new(text),
1564    ))))
1565}
1566
1567fn seed_bash_tool(id: &str) -> AcpEvent {
1568    let mut tool_call = acp::ToolCall::new(id.to_string(), format!("Run {id}"));
1569    tool_call.meta = Some(seed_tool_meta("bash"));
1570    tool_call.raw_input = Some(json!({ "command": "cargo test --module writer" }));
1571    session_update(acp::SessionUpdate::ToolCall(tool_call))
1572}
1573
1574fn seed_edit_tool(id: &str, turn: usize) -> AcpEvent {
1575    session_update(acp::SessionUpdate::ToolCall(acp::ToolCall::new(
1576        id.to_string(),
1577        format!("Editing src/module_{turn}.rs"),
1578    )))
1579}
1580
1581fn seed_spawn_tool(id: &str) -> AcpEvent {
1582    let mut tool_call = acp::ToolCall::new(id.to_string(), format!("Spawning sub-agents ({id})"));
1583    tool_call.meta = Some(seed_tool_meta("spawn_subagent"));
1584    session_update(acp::SessionUpdate::ToolCall(tool_call))
1585}
1586
1587fn seed_tool_meta(tool_name: &str) -> acp::Meta {
1588    let mut meta = serde_json::Map::new();
1589    meta.insert(AETHER_TOOL_NAME_META_KEY.to_string(), json!(tool_name));
1590    meta
1591}
1592
1593pub fn tool_completed(id: &str) -> AcpEvent {
1594    session_update(acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
1595        id.to_string(),
1596        acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed),
1597    )))
1598}
1599
1600fn seed_tool_diff(id: &str, turn: usize) -> AcpEvent {
1601    let diff = acp::Diff::new(format!("src/module_{turn}.rs"), SEED_DIFF_AFTER).old_text(SEED_DIFF_BEFORE);
1602    session_update(acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
1603        id.to_string(),
1604        acp::ToolCallUpdateFields::new()
1605            .content(vec![acp::ToolCallContent::Diff(diff)])
1606            .status(acp::ToolCallStatus::Completed),
1607    )))
1608}
1609
1610fn seed_sub_agent(parent: &str, task: &str, agent: &str, event: SubAgentEvent) -> AcpEvent {
1611    AcpEvent::SubAgentProgress(SubAgentProgressParams {
1612        parent_tool_id: parent.to_string(),
1613        task_id: task.to_string(),
1614        agent_name: agent.to_string(),
1615        event,
1616    })
1617}
1618
1619fn prose_message(total_bytes: usize) -> String {
1620    let mut message = String::new();
1621    let mut sentence = 0;
1622    while message.len() < total_bytes {
1623        for _ in 0..4 {
1624            let _ =
1625                write!(message, "Sentence {sentence} carries ordinary words so wrapping and parsing do real work. ");
1626            sentence += 1;
1627        }
1628        message.push_str("\n\n");
1629    }
1630    message
1631}
1632
1633fn code_block_message(total_bytes: usize) -> String {
1634    let mut message = String::from("```rust\n");
1635    let mut line = 0;
1636    while message.len() < total_bytes {
1637        let _ = writeln!(message, "let value_{line} = state.reconcile(incoming[{line}]).expect(\"delta accepted\");");
1638        line += 1;
1639    }
1640    message.push_str("```\n");
1641    message
1642}
1643
1644fn thought_message(total_bytes: usize) -> String {
1645    let mut message = String::new();
1646    let mut step = 0;
1647    while message.len() < total_bytes {
1648        let _ = writeln!(message, "Considering step {step} of the plan before acting on it.");
1649        step += 1;
1650    }
1651    message
1652}
1653
1654pub fn chunk_message(message: &str, chunk_bytes: usize) -> Vec<String> {
1655    let mut chunks = Vec::new();
1656    let mut rest = message;
1657    while !rest.is_empty() {
1658        let mut end = rest.len().min(chunk_bytes);
1659        while !rest.is_char_boundary(end) {
1660            end -= 1;
1661        }
1662        chunks.push(rest[..end].to_string());
1663        rest = &rest[end..];
1664    }
1665    chunks
1666}
1667
1668#[cfg(test)]
1669mod tests {
1670    use super::*;
1671    use crate::command::TerminalCommand;
1672    use crate::git_review::{FileStatus, StageState};
1673
1674    #[test]
1675    fn fake_executor_preserves_command_order() {
1676        let mut executor = FakeExecutor::new();
1677        executor
1678            .record([Command::Filesystem(FilesystemCommand::ListThemes), Command::Terminal(TerminalCommand::RingBell)]);
1679
1680        assert!(matches!(executor.take_commands()[..], [Command::Filesystem(_), Command::Terminal(_)]));
1681    }
1682
1683    #[test]
1684    fn fake_filesystem_persists_files_and_settings_in_memory() {
1685        let mut filesystem = FakeFilesystem::new();
1686        let path = PathBuf::from("workspace/src/main.rs");
1687        filesystem.write_file(&path, "fn main() {}");
1688        filesystem.save_settings(UiSettings::default());
1689
1690        assert_eq!(filesystem.read_to_string(&path).as_deref(), Some("fn main() {}"));
1691        assert!(filesystem.contains(Path::new("workspace/src")));
1692        assert!(filesystem.settings().is_some());
1693    }
1694
1695    #[test]
1696    fn fake_git_models_staging_and_discarding_state() {
1697        let mut git = FakeGit::new("workspace");
1698        git.add_file("src/main.rs", "initial\n");
1699        assert_eq!(git.status("src/main.rs"), Some((FileStatus::Untracked, StageState::Unstaged)));
1700
1701        git.stage("src/main.rs");
1702        assert_eq!(git.status("src/main.rs"), Some((FileStatus::Untracked, StageState::Staged)));
1703        git.commit("initial").unwrap();
1704
1705        git.write_file("src/main.rs", "changed\n");
1706        assert_eq!(git.status("src/main.rs"), Some((FileStatus::Modified, StageState::Unstaged)));
1707        git.discard("src/main.rs");
1708        assert_eq!(git.file("src/main.rs").and_then(|file| file.contents), Some(b"initial\n".to_vec()));
1709    }
1710}