use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use std::time::{Duration, Instant};
use chrono::Utc;
use super::{Action, Context, DETAIL_WORDS, Result, View, first_words, state_style};
use crate::domain::{Task, TaskState, TaskUsage};
use crate::store::Transition;
use crate::term::{DEFAULT_SCROLLBACK, Panes, keys};
use crate::tmux::{self, ControlClient, Tmux};
use crate::usage::{compact, short_model};
const DRAIN_LIMIT: usize = 512;
pub struct TaskView {
task_id: i64,
task: Option<Task>,
tmux: Tmux,
client: Option<ControlClient>,
panes: Panes,
pane: Option<String>,
unavailable: Option<String>,
size: (u16, u16),
dirty: bool,
checked_task: Option<Instant>,
repos: Vec<String>,
branch: Option<String>,
}
impl TaskView {
pub fn new(task_id: i64) -> Self {
Self {
task_id,
task: None,
tmux: Tmux::new(),
client: None,
panes: Panes::new(tmux::DEFAULT_SIZE, DEFAULT_SCROLLBACK),
pane: None,
unavailable: None,
size: tmux::DEFAULT_SIZE,
dirty: true,
checked_task: None,
repos: Vec::new(),
branch: None,
}
}
#[cfg(test)]
pub(crate) fn with_tmux(mut self, tmux: Tmux) -> Self {
self.tmux = tmux;
self
}
pub fn is_attached(&self) -> bool {
self.client.is_some()
}
fn ensure_attached(&mut self, ctx: &mut Context) -> Result<()> {
if self.client.is_some() {
return Ok(());
}
let task = ctx.store.get_task(self.task_id)?;
let session = task.session_name.clone();
self.task = Some(task);
let live = match session.as_deref() {
None => false,
Some(name) => match self.tmux.session_exists(name) {
Ok(live) => live,
Err(err) => {
self.say_unavailable(format!("cannot reach tmux: {err}"));
return Ok(());
}
},
};
if !live {
self.say_unavailable(match self.task.as_ref().map(|t| t.state) {
Some(TaskState::Queued) => {
"not started yet — the daemon will launch it when a slot frees".to_string()
}
_ => match &session {
Some(name) => format!("no tmux session named {name}"),
None => "no tmux session was ever started for this task".to_string(),
},
});
return Ok(());
}
let session = session.expect("live implies a name");
self.dirty = true;
match ControlClient::attach(&self.tmux, &session, self.size) {
Ok(client) => {
self.pane = self.tmux.list_panes(&session)?.into_iter().next();
self.client = Some(client);
self.unavailable = None;
}
Err(err) => self.unavailable = Some(format!("could not attach: {err}")),
}
Ok(())
}
fn answered(&mut self, ctx: &mut Context) {
let Some(task) = &self.task else { return };
if !matches!(task.state, TaskState::Blocked | TaskState::AwaitingReview) {
return;
}
let id = task.id;
self.dirty = true;
match ctx
.store
.transition(id, TaskState::Running, Transition::Plain, Utc::now())
{
Ok(task) => self.task = Some(task),
Err(_) => self.task = ctx.store.get_task(id).ok(),
}
}
fn refresh(&mut self, ctx: &mut Context) {
if self
.checked_task
.is_some_and(|at| at.elapsed() < super::TICK)
{
return;
}
self.catch_up_on_usage(ctx);
let task = ctx.store.get_task(self.task_id).ok();
if task != self.task {
self.task = task;
self.dirty = true;
}
self.load_repos(ctx);
self.checked_task = Some(Instant::now());
}
fn catch_up_on_usage(&mut self, ctx: &mut Context) {
let Ok(task) = ctx.store.get_task(self.task_id) else {
return;
};
let Some(path) = task.usage.transcript_path.clone() else {
return;
};
let Ok(usage) = crate::usage::read_from(&path, task.usage.transcript_offset) else {
return;
};
if usage.offset == task.usage.transcript_offset {
return;
}
let _ = ctx.store.record_usage(task.id, &usage);
}
fn load_repos(&mut self, ctx: &mut Context) {
let Ok(links) = ctx.store.list_task_repos(self.task_id) else {
return;
};
let names: Vec<String> = links
.iter()
.filter_map(|link| ctx.store.get_repo(link.repo_id).ok())
.map(|repo| repo.name)
.collect();
let branch = links.iter().find_map(|link| link.branch.clone());
if names != self.repos || branch != self.branch {
self.repos = names;
self.branch = branch;
self.dirty = true;
}
}
fn scroll_by(&mut self, lines: isize) -> bool {
match self.emulated() {
Some(terminal) => terminal.scroll_by(lines),
None => false,
}
}
fn scroll_to_live(&mut self) -> bool {
match self.emulated() {
Some(terminal) if terminal.scrollback() > 0 => {
terminal.scroll_to_bottom();
true
}
_ => false,
}
}
fn emulated(&mut self) -> Option<&mut crate::term::PaneTerminal> {
let pane = self.pane.clone()?;
self.panes.get_mut(&pane)
}
fn say_unavailable(&mut self, reason: String) {
if self.unavailable.as_deref() != Some(reason.as_str()) {
self.dirty = true;
self.unavailable = Some(reason);
}
}
fn ended(&mut self) {
self.dirty = true;
self.unavailable = Some("the session ended".into());
self.client = None;
if let Some(pane) = self.pane.take() {
self.panes.remove(&pane);
}
}
fn drain(&mut self) {
let Some(client) = &self.client else {
return;
};
for _ in 0..DRAIN_LIMIT {
match client.try_event() {
Ok(event) => {
self.dirty = true;
if let tmux::Event::Exit { .. } = event {
self.ended();
return;
}
self.panes.apply(&event);
}
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.ended();
return;
}
}
}
self.dirty = true;
}
}
impl View for TaskView {
fn title(&self) -> String {
match &self.task {
Some(task) => format!("Task {} — {}", task.id, task.title),
None => format!("Task {}", self.task_id),
}
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &mut Context) {
self.refresh(ctx);
let _ = self.ensure_attached(ctx);
self.drain();
self.dirty = false;
let [info, body] =
Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).areas(area);
if let Some(task) = &self.task {
let mut first = vec![Span::styled(
format!(" {} ", task.state),
state_style(task.state).add_modifier(Modifier::BOLD),
)];
if !self.repos.is_empty() {
first.push(Span::styled(
format!(" {}", self.repos.join(", ")),
Style::default().fg(Color::Cyan),
));
}
if let Some(branch) = &self.branch {
first.push(Span::styled(
format!(" {branch}"),
Style::default().add_modifier(Modifier::DIM),
));
}
if let Some(spent) = usage_summary(&task.usage) {
first.push(Span::styled(
format!(" {spent}"),
Style::default().fg(Color::Magenta),
));
}
let mut second = Vec::new();
if let Some(reason) = task
.blocked_reason
.as_ref()
.or(task.failure_reason.as_ref())
{
second.push(Span::styled(
format!(" {}", first_words(reason, DETAIL_WORDS)),
Style::default().fg(Color::Yellow),
));
}
second.push(Span::styled(
format!(" {}", task.workspace_dir.display()),
Style::default().add_modifier(Modifier::DIM),
));
frame.render_widget(
Paragraph::new(vec![Line::from(first), Line::from(second)]),
info,
);
}
let inner = (body.width.saturating_sub(2), body.height.saturating_sub(2));
if inner.0 > 0 && inner.1 > 0 && inner != self.size {
self.size = inner;
self.panes.resize(inner);
if let Some(client) = &mut self.client {
let _ = client.resize(inner.0, inner.1);
}
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().add_modifier(Modifier::DIM))
.title(match &self.pane {
Some(pane) => format!("agent {pane}"),
None => "agent".to_string(),
});
match self.pane.clone().and_then(|p| self.panes.get(&p)) {
Some(term) => {
let area = block.inner(body);
frame.render_widget(block, body);
frame.render_widget(term, area);
}
None => {
let message = self
.unavailable
.clone()
.unwrap_or_else(|| "waiting for output…".into());
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
format!(" {message}"),
Style::default().add_modifier(Modifier::DIM),
)))
.block(block),
body,
);
}
}
}
fn handle_mouse(&mut self, mouse: MouseEvent, _ctx: &mut Context) -> Result<Action> {
let lines = match mouse.kind {
MouseEventKind::ScrollUp => 3,
MouseEventKind::ScrollDown => -3,
_ => return Ok(Action::None),
};
self.dirty |= self.scroll_by(lines);
Ok(Action::None)
}
fn handle_key(&mut self, key: KeyEvent, ctx: &mut Context) -> Result<Action> {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('q') {
return Ok(Action::Pop);
}
self.dirty |= self.scroll_to_live();
if key
.modifiers
.intersects(KeyModifiers::SUPER | KeyModifiers::CONTROL)
&& key.code == KeyCode::Esc
{
return Ok(Action::Pop);
}
if let (Some(client), Some(pane)) = (self.client.as_mut(), self.pane.as_ref())
&& let Some(command) = keys::send_keys_command(pane, key)
{
match client.send_command(&command) {
Ok(()) => {
if key.code == KeyCode::Enter {
self.answered(ctx);
}
}
Err(err) => {
self.unavailable = Some(format!("the session stopped listening: {err}"));
self.client = None;
self.dirty = true;
}
}
}
Ok(Action::None)
}
fn tick(&mut self, ctx: &mut Context) -> Result<()> {
if self
.checked_task
.is_none_or(|at| at.elapsed() >= super::TICK)
{
let task = ctx.store.get_task(self.task_id).ok();
if task != self.task {
self.task = task;
self.dirty = true;
}
self.checked_task = Some(Instant::now());
}
self.ensure_attached(ctx)?;
self.drain();
Ok(())
}
fn poll_interval(&self) -> Duration {
if self.client.is_some() {
crate::tui::LIVE_TICK
} else {
super::TICK
}
}
fn dirty(&self) -> bool {
self.dirty
}
fn keys(&self) -> Vec<(&'static str, &'static str)> {
vec![("^esc/^q", "back"), ("any", "→ agent")]
}
fn captures_input(&self) -> bool {
true
}
}
pub(super) fn usage_summary(usage: &TaskUsage) -> Option<String> {
if !usage.is_known() {
return None;
}
let mut parts = Vec::new();
if let Some(model) = &usage.model {
parts.push(short_model(model));
}
if let Some(context) = usage.context_tokens {
parts.push(format!("{} ctx", compact(context)));
}
if let Some(output) = usage.output_tokens {
parts.push(format!("{} out", compact(output)));
}
Some(parts.join(" · "))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::Repo;
use crate::git::testing::init_repo;
use crate::launcher::Launcher;
use crate::store::{Store, Transition};
use crate::tmux::testing::TestServer;
use crate::tui::testing::{press, render_view, tick_view, wheel};
use crate::worktree::WorktreeManager;
use chrono::{DateTime, Utc};
use std::path::PathBuf;
use tempfile::TempDir;
fn at(secs: i64) -> DateTime<Utc> {
DateTime::from_timestamp(secs, 0).unwrap()
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
struct Fixture {
_tmp: TempDir,
server: TestServer,
store: Store,
launcher: Launcher,
tasks_dir: PathBuf,
repos_dir: PathBuf,
}
impl Fixture {
fn new() -> Self {
let tmp = TempDir::new().unwrap();
let server = TestServer::new();
let repos_dir = tmp.path().join("repos");
let tasks_dir = tmp.path().join("tasks");
std::fs::create_dir_all(&repos_dir).unwrap();
let launcher = Launcher::new(
server.tmux.clone(),
WorktreeManager::new(&tasks_dir),
PathBuf::from("/bin/marver"),
tmp.path().join("m.sock"),
)
.harness(crate::launcher::testing::stub_agent(tmp.path()));
Self {
store: Store::open_in_memory().unwrap(),
launcher,
tasks_dir,
repos_dir,
server,
_tmp: tmp,
}
}
fn repo(&self, name: &str) -> Repo {
let path = self.repos_dir.join(name);
init_repo(&path, "main");
self.store.upsert_repo(&path, name, at(0)).unwrap()
}
fn queued(&mut self, title: &str, repos: &[Repo]) -> Task {
let ids: Vec<i64> = repos.iter().map(|r| r.id).collect();
self.store
.create_task(title, "do it", &self.tasks_dir, &ids, at(0))
.unwrap()
}
fn launched(&mut self, title: &str) -> Task {
let repo = self.repo("api");
let task = self.queued(title, std::slice::from_ref(&repo));
self.launcher.launch(&mut self.store, &task, at(1)).unwrap();
self.store
.transition(task.id, TaskState::Running, Transition::Plain, at(2))
.unwrap();
self.store.get_task(task.id).unwrap()
}
fn view(&self, task: &Task) -> TaskView {
TaskView::new(task.id).with_tmux(self.server.tmux.clone())
}
}
fn sitting_in(fx: &mut Fixture, task: &Task, state: TaskState) -> TaskView {
if state == TaskState::AwaitingReview {
fx.store
.transition(task.id, TaskState::AwaitingReview, Transition::Plain, at(3))
.unwrap();
} else {
fx.store
.transition(
task.id,
TaskState::Blocked,
Transition::Blocked(crate::store::BlockedInfo::new(
crate::domain::BlockedKind::PermissionPrompt,
)),
at(3),
)
.unwrap();
}
let mut view = fx.view(task);
render_view(&mut view, &mut fx.store, 80, 12);
view
}
#[test]
fn answering_the_agent_puts_the_task_back_to_work() {
for state in [TaskState::Blocked, TaskState::AwaitingReview] {
let mut fx = Fixture::new();
let task = fx.launched("fix auth");
let mut view = sitting_in(&mut fx, &task, state);
assert!(view.is_attached(), "the pane must be live for {state}");
for c in "carry on".chars() {
press(&mut view, &mut fx.store, key(KeyCode::Char(c)));
}
assert_eq!(
fx.store.get_task(task.id).unwrap().state,
state,
"typing alone is not an answer"
);
press(&mut view, &mut fx.store, key(KeyCode::Enter));
assert_eq!(
fx.store.get_task(task.id).unwrap().state,
TaskState::Running,
"submitting from {state} should resume the task"
);
}
}
#[test]
fn the_wheel_scrolls_this_screens_copy_and_never_reaches_the_agent() {
let mut fx = Fixture::new();
let task = fx.launched("fix auth");
let mut view = fx.view(&task);
view.pane = Some("%0".into());
let pane = view.panes.ensure("%0");
for n in 0..60 {
pane.feed(format!("line {n}\r\n").as_bytes());
}
wheel(&mut view, &mut fx.store, true);
assert_eq!(
view.panes.get("%0").unwrap().scrollback(),
3,
"three lines a notch, as a terminal would scroll"
);
assert!(view.client.is_none(), "and nothing was sent to the agent");
}
#[test]
fn typing_at_the_agent_comes_back_to_what_it_is_saying_now() {
let mut fx = Fixture::new();
let task = fx.launched("fix auth");
let mut view = fx.view(&task);
view.pane = Some("%0".into());
let pane = view.panes.ensure("%0");
for n in 0..60 {
pane.feed(format!("line {n}\r\n").as_bytes());
}
wheel(&mut view, &mut fx.store, true);
assert_eq!(view.panes.get("%0").unwrap().scrollback(), 3);
press(&mut view, &mut fx.store, key(KeyCode::Char('h')));
assert_eq!(view.panes.get("%0").unwrap().scrollback(), 0);
}
#[test]
fn the_header_says_what_the_agent_has_spent() {
let mut fx = Fixture::new();
let task = fx.launched("fix auth");
fx.store
.record_usage(
task.id,
&crate::usage::Usage {
model: Some("claude-opus-5".into()),
context_tokens: Some(309_115),
output_tokens: 333_355,
offset: 2_027_264,
start: 0,
},
)
.unwrap();
let mut view = fx.view(&task);
let screen = render_view(&mut view, &mut fx.store, 120, 12);
assert!(screen[0].contains("opus-5"), "{screen:?}");
assert!(screen[0].contains("310k ctx"), "{screen:?}");
assert!(screen[0].contains("334k out"), "{screen:?}");
}
#[test]
fn opening_a_finished_task_reads_the_tokens_no_hook_ever_reported() {
use std::io::Write;
let mut fx = Fixture::new();
let task = fx.launched("fix auth");
let path = fx._tmp.path().join("session.jsonl");
let mut file = std::fs::File::create(&path).unwrap();
writeln!(
file,
r#"{{"type":"assistant","message":{{"model":"claude-opus-5","usage":{{"input_tokens":900,"cache_read_input_tokens":100,"cache_creation_input_tokens":0,"output_tokens":42}}}}}}"#
)
.unwrap();
fx.store.set_transcript_path(task.id, &path).unwrap();
let mut view = fx.view(&task);
render_view(&mut view, &mut fx.store, 120, 12);
view.checked_task = None;
let screen = render_view(&mut view, &mut fx.store, 120, 12);
assert!(screen[0].contains("opus-5"), "{screen:?}");
assert!(screen[0].contains("1000 ctx"), "{screen:?}");
assert!(screen[0].contains("42 out"), "{screen:?}");
}
#[test]
fn a_screen_left_open_stops_rereading_once_it_has_caught_up() {
use std::io::Write;
let mut fx = Fixture::new();
let task = fx.launched("fix auth");
let path = fx._tmp.path().join("session.jsonl");
let mut file = std::fs::File::create(&path).unwrap();
writeln!(
file,
r#"{{"type":"assistant","message":{{"model":"claude-opus-5","usage":{{"output_tokens":42}}}}}}"#
)
.unwrap();
fx.store.set_transcript_path(task.id, &path).unwrap();
let mut view = fx.view(&task);
for _ in 0..4 {
view.checked_task = None;
render_view(&mut view, &mut fx.store, 120, 12);
}
assert_eq!(
fx.store.get_task(task.id).unwrap().usage.output_tokens,
Some(42),
"the same turn must not be counted once per tick"
);
}
#[test]
fn catching_up_does_not_recount_what_the_daemon_already_read() {
use std::io::Write;
let mut fx = Fixture::new();
let task = fx.launched("fix auth");
let path = fx._tmp.path().join("session.jsonl");
let turn = |file: &mut std::fs::File, out: u64| {
writeln!(
file,
r#"{{"type":"assistant","message":{{"model":"claude-opus-5","usage":{{"output_tokens":{out}}}}}}}"#
)
.unwrap();
};
let mut file = std::fs::File::create(&path).unwrap();
turn(&mut file, 100);
fx.store.set_transcript_path(task.id, &path).unwrap();
let mut view = fx.view(&task);
render_view(&mut view, &mut fx.store, 120, 12);
assert_eq!(
fx.store.get_task(task.id).unwrap().usage.output_tokens,
Some(100)
);
let mut file = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
turn(&mut file, 50);
let offset = fx.store.get_task(task.id).unwrap().usage.transcript_offset;
let read = crate::usage::read_from(&path, offset).unwrap();
fx.store.record_usage(task.id, &read).unwrap();
assert_eq!(
fx.store.get_task(task.id).unwrap().usage.output_tokens,
Some(150)
);
view.checked_task = None;
render_view(&mut view, &mut fx.store, 120, 12);
assert_eq!(
fx.store.get_task(task.id).unwrap().usage.output_tokens,
Some(150),
"the second turn must not be counted once per reader"
);
}
#[test]
fn a_task_that_has_spent_nothing_yet_says_nothing() {
let mut fx = Fixture::new();
let task = fx.launched("fix auth");
let mut view = fx.view(&task);
let screen = render_view(&mut view, &mut fx.store, 120, 12);
assert!(!screen[0].contains("ctx"), "{screen:?}");
assert!(!screen[0].contains(" out"), "{screen:?}");
}
#[test]
fn the_summary_shows_whatever_is_known_and_leaves_out_what_is_not() {
assert_eq!(usage_summary(&TaskUsage::default()), None);
assert_eq!(
usage_summary(&TaskUsage {
model: Some("claude-sonnet-5".into()),
..TaskUsage::default()
}),
Some("sonnet-5".to_string())
);
assert_eq!(
usage_summary(&TaskUsage {
context_tokens: Some(12_000),
output_tokens: Some(400),
..TaskUsage::default()
}),
Some("12k ctx · 400 out".to_string())
);
}
#[test]
fn the_header_names_the_repo_being_worked_in() {
let mut fx = Fixture::new();
let task = fx.launched("fix auth");
let mut view = fx.view(&task);
let screen = render_view(&mut view, &mut fx.store, 100, 12);
assert!(
screen[0].contains("api"),
"the repo should be on the first line: {screen:?}"
);
assert!(
screen[0].contains(&format!("marver/{}", task.id)),
"and the branch it is on: {screen:?}"
);
assert!(
screen[1].contains(&task.workspace_dir.display().to_string()),
"the path moves to the second line: {screen:?}"
);
}
#[test]
fn a_multi_repo_task_names_every_repo() {
let mut fx = Fixture::new();
let api = fx.repo("api");
let web = fx.repo("web");
let task = fx.queued("cross-cutting", &[api, web]);
let mut view = fx.view(&task);
let screen = render_view(&mut view, &mut fx.store, 100, 12);
assert!(screen[0].contains("api"), "{screen:?}");
assert!(screen[0].contains("web"), "{screen:?}");
}
#[test]
fn a_long_reason_is_cut_instead_of_taking_the_header_with_it() {
let mut fx = Fixture::new();
let task = fx.launched("fix auth");
fx.store
.transition(
task.id,
TaskState::Blocked,
Transition::Blocked(crate::store::BlockedInfo::with_reason(
crate::domain::BlockedKind::PermissionPrompt,
"Claude needs your permission to run a command that will\ntouch \
several files across the repository and cannot be undone",
)),
at(3),
)
.unwrap();
let mut view = fx.view(&task);
let screen = render_view(&mut view, &mut fx.store, 100, 12);
let header = format!("{}{}", screen[0], screen[1]);
assert!(header.contains("Claude needs your"), "{header:?}");
assert!(header.contains('…'), "it should say it was cut: {header:?}");
assert!(
!header.contains("cannot be undone"),
"the tail belongs in the pane, not the header: {header:?}"
);
assert!(
screen[2].trim().is_empty() || screen[2].contains('┌'),
"the header stayed two lines: {screen:?}"
);
}
#[test]
#[ignore = "measures, does not assert"]
fn bench_idle_wakeup() {
let mut fx = Fixture::new();
let task = fx.launched("fix auth");
let mut view = fx.view(&task);
render_view(&mut view, &mut fx.store, 120, 40);
for _ in 0..40 {
tick_view(&mut view, &mut fx.store);
render_view(&mut view, &mut fx.store, 120, 40);
std::thread::sleep(std::time::Duration::from_millis(25));
}
let rounds = 10_000;
let started = std::time::Instant::now();
for _ in 0..rounds {
tick_view(&mut view, &mut fx.store);
}
let each = started.elapsed() / rounds;
let duty = each.as_secs_f64() / crate::tui::LIVE_TICK.as_secs_f64() * 100.0;
println!(
"idle wakeup: {each:?} each, {duty:.3}% of one core at {:?} polling",
crate::tui::LIVE_TICK
);
assert!(!view.dirty(), "a quiet pane should still owe no frame");
let rounds = 500;
let started = std::time::Instant::now();
for _ in 0..rounds {
render_view(&mut view, &mut fx.store, 120, 40);
}
println!(
"full render: {:?} each at 120x40",
started.elapsed() / rounds
);
}
#[test]
fn a_live_pane_asks_to_be_looked_at_often() {
let mut fx = Fixture::new();
let repo = fx.repo("web");
let queued = fx.queued("waiting", std::slice::from_ref(&repo));
let mut idle = fx.view(&queued);
render_view(&mut idle, &mut fx.store, 80, 12);
assert!(!idle.is_attached());
assert_eq!(
idle.poll_interval(),
crate::tui::TICK,
"a screen with nothing live behind it should not spin"
);
let task = fx.launched("fix auth");
let mut live = fx.view(&task);
render_view(&mut live, &mut fx.store, 80, 12);
assert!(live.is_attached());
assert_eq!(live.poll_interval(), crate::tui::LIVE_TICK);
}
#[test]
fn a_quiet_pane_costs_no_redraws() {
let mut fx = Fixture::new();
let task = fx.launched("fix auth");
let mut view = fx.view(&task);
render_view(&mut view, &mut fx.store, 80, 12);
for _ in 0..40 {
tick_view(&mut view, &mut fx.store);
render_view(&mut view, &mut fx.store, 80, 12);
std::thread::sleep(std::time::Duration::from_millis(25));
}
assert!(!view.dirty(), "a rendered view owes nothing");
tick_view(&mut view, &mut fx.store);
assert!(
!view.dirty(),
"a tick that drained nothing must not ask for a frame"
);
}
#[test]
fn output_from_the_agent_asks_for_a_frame() {
let mut fx = Fixture::new();
let task = fx.launched("fix auth");
let mut view = fx.view(&task);
render_view(&mut view, &mut fx.store, 80, 12);
let pane = view.pane.clone().unwrap();
fx.server
.tmux
.send_keys(&pane, "echo MARVERECHO")
.expect("type");
fx.server.tmux.send_key(&pane, "Enter").expect("enter");
let mut asked = false;
for _ in 0..80 {
tick_view(&mut view, &mut fx.store);
if view.dirty() {
asked = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
assert!(asked, "output must mark the view as owing a frame");
}
#[test]
fn answering_a_task_that_is_already_running_changes_nothing() {
let mut fx = Fixture::new();
let task = fx.launched("fix auth");
let mut view = fx.view(&task);
render_view(&mut view, &mut fx.store, 80, 12);
let before = fx.store.get_task(task.id).unwrap();
press(&mut view, &mut fx.store, key(KeyCode::Enter));
let after = fx.store.get_task(task.id).unwrap();
assert_eq!(after.state, TaskState::Running);
assert_eq!(after.updated_at, before.updated_at);
}
#[test]
fn a_finished_task_is_not_revived_by_a_keystroke() {
let mut fx = Fixture::new();
let task = fx.launched("fix auth");
fx.store
.transition(task.id, TaskState::Cancelled, Transition::Plain, at(3))
.unwrap();
let mut view = fx.view(&task);
render_view(&mut view, &mut fx.store, 80, 12);
press(&mut view, &mut fx.store, key(KeyCode::Enter));
assert_eq!(
fx.store.get_task(task.id).unwrap().state,
TaskState::Cancelled,
"terminal states never resume"
);
}
#[test]
fn a_queued_task_explains_why_there_is_no_terminal() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.queued("waiting", std::slice::from_ref(&repo));
let mut view = fx.view(&task);
let screen = render_view(&mut view, &mut fx.store, 70, 12);
assert!(
screen.iter().any(|l| l.contains("not started yet")),
"an empty pane should not look broken: {screen:?}"
);
assert!(!view.is_attached());
}
#[test]
fn the_header_shows_the_state_and_workspace() {
let mut fx = Fixture::new();
let task = fx.launched("fix auth");
let mut view = fx.view(&task);
let screen = render_view(&mut view, &mut fx.store, 100, 12);
assert!(screen[0].contains("running"), "{screen:?}");
assert!(
screen[0].contains(&task.id.to_string()),
"the workspace path names the task: {screen:?}"
);
}
#[test]
fn a_launched_task_attaches_and_shows_its_agent() {
let mut fx = Fixture::new();
let task = fx.launched("fix auth");
let mut view = fx.view(&task);
let mut screen = Vec::new();
for _ in 0..80 {
screen = render_view(&mut view, &mut fx.store, 100, 16);
if screen.iter().any(|l| l.contains("ARG[do it]")) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
assert!(view.is_attached(), "should have a control connection");
assert!(
screen.iter().any(|l| l.contains("ARG[do it]")),
"the agent's output should reach the screen: {screen:?}"
);
}
#[test]
fn typing_reaches_the_agents_pane() {
let mut fx = Fixture::new();
let task = fx.launched("fix auth");
let mut view = fx.view(&task);
for _ in 0..40 {
render_view(&mut view, &mut fx.store, 100, 16);
if view.is_attached() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
assert!(view.is_attached());
for c in "printf MARVERTYPED".chars() {
press(&mut view, &mut fx.store, key(KeyCode::Char(c)));
}
press(&mut view, &mut fx.store, key(KeyCode::Enter));
let pane = view.pane.clone().unwrap();
let mut captured = String::new();
for _ in 0..80 {
captured = fx.server.tmux.capture_pane(&pane).unwrap();
if captured.contains("MARVERTYPED") {
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
assert!(
captured.contains("MARVERTYPED"),
"keys must reach the real pane: {captured:?}"
);
}
#[test]
fn ctrl_bracket_leaves_but_ordinary_keys_do_not() {
let mut fx = Fixture::new();
let task = fx.launched("fix auth");
let mut view = fx.view(&task);
render_view(&mut view, &mut fx.store, 80, 12);
for code in [KeyCode::Char('q'), KeyCode::Esc, KeyCode::Char('c')] {
assert!(
matches!(press(&mut view, &mut fx.store, key(code)), Action::None),
"{code:?} must go to the agent, not close the screen"
);
}
let mut fx = Fixture::new();
let task = fx.launched("fix auth");
let mut view = fx.view(&task);
render_view(&mut view, &mut fx.store, 80, 12);
let chord = KeyEvent::new(KeyCode::Esc, KeyModifiers::SUPER);
assert!(matches!(
press(&mut view, &mut fx.store, chord),
Action::Pop
));
let mut view = fx.view(&task);
render_view(&mut view, &mut fx.store, 80, 12);
let chord = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL);
assert!(
matches!(press(&mut view, &mut fx.store, chord), Action::Pop),
"ctrl-q must leave; there is no other way out"
);
for code in [KeyCode::Char(']'), KeyCode::Char('5')] {
let mut view = fx.view(&task);
render_view(&mut view, &mut fx.store, 80, 12);
let chord = KeyEvent::new(code, KeyModifiers::CONTROL);
assert!(
matches!(press(&mut view, &mut fx.store, chord), Action::None),
"ctrl+{code:?} is the agent's now"
);
}
}
#[test]
fn killing_the_session_is_reported_rather_than_hanging() {
let mut fx = Fixture::new();
let task = fx.launched("fix auth");
let mut view = fx.view(&task);
for _ in 0..40 {
render_view(&mut view, &mut fx.store, 80, 12);
if view.is_attached() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
fx.server
.tmux
.kill_session(&tmux::session_name(None, task.id))
.unwrap();
let mut screen = Vec::new();
for _ in 0..80 {
screen = render_view(&mut view, &mut fx.store, 80, 12);
if screen.iter().any(|l| l.contains("session ended")) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
assert!(
screen.iter().any(|l| l.contains("session ended")),
"a vanished agent should say so: {screen:?}"
);
}
}