use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use super::{Action, Context, Result, View, state_style};
use crate::domain::{Task, TaskState};
use crate::term::{DEFAULT_SCROLLBACK, Panes, keys};
use crate::tmux::{self, ControlClient, Tmux};
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),
}
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,
}
}
pub 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()
.unwrap_or_else(|| tmux::session_name(task.id));
self.task = Some(task);
if !self.tmux.has_session(&session) {
self.unavailable = Some(match self.task.as_ref().map(|t| t.state) {
Some(TaskState::Queued) => {
"not started yet — the daemon will launch it when a slot frees".into()
}
_ => format!("no tmux session named {session}"),
});
return Ok(());
}
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 drain(&mut self) {
let Some(client) = &self.client else {
return;
};
for _ in 0..DRAIN_LIMIT {
match client.try_event() {
Ok(event) => {
if let tmux::Event::Exit { .. } = event {
self.unavailable = Some("the session ended".into());
self.client = None;
return;
}
self.panes.apply(&event);
}
Err(_) => return,
}
}
}
}
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) {
let _ = self.ensure_attached(ctx);
self.drain();
let [info, body] =
Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).areas(area);
if let Some(task) = &self.task {
let mut spans = vec![
Span::styled(
format!(" {} ", task.state),
state_style(task.state).add_modifier(Modifier::BOLD),
),
Span::styled(
task.workspace_dir.display().to_string(),
Style::default().add_modifier(Modifier::DIM),
),
];
if let Some(reason) = task
.blocked_reason
.as_ref()
.or(task.failure_reason.as_ref())
{
spans.push(Span::styled(
format!(" {reason}"),
Style::default().fg(Color::Yellow),
));
}
frame.render_widget(Paragraph::new(Line::from(spans)), 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_key(&mut self, key: KeyEvent, _ctx: &mut Context) -> Result<Action> {
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char(']') | KeyCode::Char('5'))
{
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)
{
let _ = client.send_command(&command);
}
Ok(Action::None)
}
fn tick(&mut self, ctx: &mut Context) -> Result<()> {
self.task = ctx.store.get_task(self.task_id).ok();
self.ensure_attached(ctx)?;
self.drain();
Ok(())
}
fn keys(&self) -> Vec<(&'static str, &'static str)> {
vec![("^]", "back"), ("any", "→ agent")]
}
fn captures_input(&self) -> bool {
true
}
}
#[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};
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"),
)
.agent(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())
}
}
#[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"
);
}
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::Pop),
"ctrl+{code:?} must leave; there is no other way out"
);
}
}
#[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(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:?}"
);
}
}