mod command;
#[cfg(test)]
mod flows;
mod mention;
mod pane;
mod session;
mod theme;
mod thread;
mod view;
use crate::chord::{self, Action as Chord, label};
use crate::init;
use crate::mode::Mode;
use crate::workspace::Workspace;
use command::Command;
use ostraka_runtime::promote::{self, NotPromoted};
use ostraka_runtime::{index, orchestrator};
use ratatui::crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event as TermEvent, KeyCode, KeyEvent,
KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::layout::Position;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::time::Duration;
use view::{Agent, App, Detail, Dialog, Focus, Screen};
type Outcome = Result<bool, Box<dyn std::error::Error>>;
const TICK: Duration = Duration::from_millis(250);
pub fn run(workspace: &Workspace) -> Outcome {
if !std::io::stdout().is_terminal() {
return Err("`ostraka tui` needs a terminal. For a pipe or a log, \
`ostraka runs` prints the same listing, and `--json` \
prints it for a machine."
.into());
}
chord::install(chord::Keys::load()?);
let records_root = workspace.records();
let mut app = open(workspace, &records_root)?;
let mut terminal = ratatui::try_init()?;
let restore = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
release_input();
restore(info);
}));
capture_input();
let result = event_loop(&mut terminal, &mut app, &records_root);
release_input();
ratatui::restore();
result?;
Ok(true)
}
fn agents(workspace: &Workspace) -> Vec<Agent> {
use ostraka_adapter::{VendorAdapter, process::ProcessAdapter};
let profiles = workspace.profiles().unwrap_or_default();
let ids: Vec<String> = profiles.iter().map(|p| p.id.clone()).collect();
let mut agents: Vec<Agent> = profiles
.into_iter()
.map(|profile| {
let adapter = ProcessAdapter::new(profile);
let availability = adapter.probe();
Agent {
id: adapter.id().to_string(),
ready: availability.is_ready(),
note: crate::discover::describe(&availability),
configured: true,
}
})
.collect();
agents.sort_by(|a, b| a.id.cmp(&b.id));
agents.extend(
crate::discover::unconfigured(&ids)
.into_iter()
.filter(|f| f.ready())
.map(|f| Agent {
id: f.id,
ready: true,
note: crate::discover::describe(&f.availability),
configured: false,
}),
);
agents
}
fn open(workspace: &Workspace, records_root: &Path) -> Result<App, Box<dyn std::error::Error>> {
let mut app = App::new(workspace.clone(), index::list(records_root)?);
app.pane_mut().repository = workspace.repository(None).ok();
app.setup = Some(init::plan(&workspace.root)).filter(|plan| !plan.runnable());
app.blocked = blocking(&app).map(|r| r.problem);
load_detail(&mut app, records_root);
Ok(app)
}
fn event_loop(
terminal: &mut ratatui::DefaultTerminal,
app: &mut App,
records_root: &Path,
) -> std::io::Result<()> {
while !app.quit {
app.tick = app.tick.wrapping_add(1);
take_stock(app, records_root);
terminal.draw(|frame| view::draw(frame, app))?;
if !event::poll(TICK)? {
continue;
}
match event::read()? {
TermEvent::Key(key) if key.kind == KeyEventKind::Press => {
handle(app, key, records_root)
}
TermEvent::Mouse(event) => mouse(app, event),
_ => {}
}
}
for pane in &mut app.panes {
pane.thread.settle_worker();
}
Ok(())
}
fn capture_input() {
chord::enter();
let _ = ratatui::crossterm::execute!(std::io::stdout(), EnableMouseCapture);
}
fn release_input() {
let _ = ratatui::crossterm::execute!(std::io::stdout(), DisableMouseCapture);
chord::leave();
}
fn mouse(app: &mut App, event: MouseEvent) {
if app.dialog.is_some() {
return;
}
let column = app
.columns
.iter()
.find(|(_, area)| area.contains(Position::new(event.column, event.row)))
.map(|(index, _)| *index);
match event.kind {
MouseEventKind::Down(MouseButton::Left) if column.is_some() => {
app.focus_pane(column.unwrap_or(app.at));
app.focus = Focus::Prompt;
}
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
if column.is_some_and(|index| index != app.at) =>
{
let up = matches!(event.kind, MouseEventKind::ScrollUp);
let pane = &mut app.panes[column.unwrap_or(app.at)];
pane.scroll = pane.scroll.saturating_add_signed(if up { -3 } else { 3 });
if up {
pane.follow = false;
}
}
MouseEventKind::Down(MouseButton::Left) => {
let area = app.prompt_text;
if !area.contains(Position::new(event.column, event.row)) {
return;
}
let row = usize::from(event.row - area.y) + usize::from(app.prompt_scroll);
app.focus = Focus::Prompt;
app.pane_mut().place(row, event.column - area.x);
}
MouseEventKind::ScrollUp => app.scroll_by(-3),
MouseEventKind::ScrollDown => app.scroll_by(3),
_ => {}
}
}
fn take_stock(app: &mut App, records_root: &Path) {
let mut ended = None;
for at in 0..app.panes.len() {
if let Some(run_id) = app.panes[at].thread.settle() {
ended = Some((at, run_id));
}
}
if let Some((at, run_id)) = ended {
finished_run(app, records_root, at, Some(run_id));
}
if app.dialog.is_none() {
if let Some(offer) = app.pane_mut().thread.offer.take() {
offer_fallback(app, offer);
}
}
if let Some(rx) = app.agents_loading.take() {
match rx.try_recv() {
Ok(found) => app.agents = found,
Err(std::sync::mpsc::TryRecvError::Empty) => app.agents_loading = Some(rx),
Err(std::sync::mpsc::TryRecvError::Disconnected) => {}
}
}
if let Some(rx) = app.models_loading.take() {
match rx.try_recv() {
Ok(catalogs) => {
app.models_notes = catalogs
.iter()
.filter_map(|c| c.note.as_ref().map(|n| format!("{}: {n}", c.profile)))
.collect();
app.models = crate::models::rows(&catalogs);
}
Err(std::sync::mpsc::TryRecvError::Empty) => app.models_loading = Some(rx),
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
app.models_notes = vec!["the model listing stopped without an answer".to_string()];
}
}
}
if app.leaving && !app.anything_running() {
app.quit = true;
}
}
fn finished_run(app: &mut App, records_root: &Path, at: usize, run_id: Option<String>) {
let last = app.panes[at].thread.turns.last();
let said = last.and_then(|turn| {
turn.finished
.as_ref()
.map(|f| f.summary.clone())
.or_else(|| turn.failed.clone())
});
app.status = match (said, app.panes.len() > 1 && at != app.at) {
(Some(said), true) => Some(format!("{}: {said}", app.panes[at].title())),
(said, _) => said,
};
app.panes[at].follow = true;
if let Ok(runs) = index::list(records_root) {
app.runs = runs;
app.refilter();
}
if let Some(id) = run_id {
if let Some(at) = app
.matching
.iter()
.position(|i| app.runs.get(*i).is_some_and(|run| run.run_id == id))
{
app.selected = at;
app.forget_detail();
load_detail(app, records_root);
}
}
}
fn handle(app: &mut App, key: KeyEvent, records_root: &Path) {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return leave(app);
}
match chord::action(&key) {
Some(Chord::Commands) => return app.open(Dialog::Commands),
Some(Chord::Leader) => {
app.leader = true;
return;
}
Some(Chord::NewPane) => return perform(app, Command::NewPane, records_root),
Some(Chord::NextPane) => return perform(app, Command::NextPane, records_root),
Some(Chord::PreviousPane) => {
app.next_pane(-1);
to_work(app);
return;
}
None => {}
}
if app.leader {
app.leader = false;
if let KeyCode::Char(c) = key.code {
if let Some(command) = Command::ALL
.into_iter()
.find(|command| command.leader() == c)
{
perform(app, command, records_root);
}
}
return;
}
if app.dialog.is_some() {
dialog_key(app, key, records_root);
return;
}
app.status = None;
if app.focus == Focus::Prompt {
prompt_key(app, key, records_root);
} else {
command_key(app, key, records_root);
}
}
fn prompt_key(app: &mut App, key: KeyEvent, records_root: &Path) {
let control = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Enter if key.modifiers.contains(KeyModifiers::ALT) => app.pane_mut().insert('\n'),
KeyCode::Char('j') if control => app.pane_mut().insert('\n'),
KeyCode::Enter if app.slashing() && Mode::named(&app.pane().prompt).is_some() => {
let mode = Mode::named(&app.pane().prompt).unwrap_or_default();
app.pane_mut().replace(String::new());
app.pick = 0;
set_mode(app, mode);
}
KeyCode::BackTab => {
let next = app.thread().mode.next();
set_mode(app, next);
}
KeyCode::Enter if app.slashing() => {
let picked = app.slash_picked();
app.pane_mut().replace(String::new());
app.pick = 0;
match picked {
Some(command) => perform(app, command, records_root),
None => app.status = Some("no command by that name".to_string()),
}
}
KeyCode::Up if app.slashing() => app.pick = app.pick.saturating_sub(1),
KeyCode::Down if app.slashing() => {
app.pick = (app.pick + 1).min(app.slash_matches().len().saturating_sub(1));
}
KeyCode::Tab if app.mentioning().is_some() => {
app.complete_mention();
}
KeyCode::Up if !app.mention_matches().is_empty() => {
app.pick = app.pick.saturating_sub(1);
}
KeyCode::Down if !app.mention_matches().is_empty() => {
app.pick = (app.pick + 1).min(app.mention_matches().len().saturating_sub(1));
}
KeyCode::Enter
if app
.mention_picked()
.is_some_and(|picked| Some(picked.text) != app.mentioning()) =>
{
app.complete_mention();
}
KeyCode::Enter => start_run(app),
KeyCode::Esc => app.focus = Focus::Keys,
KeyCode::Backspace => {
app.pane_mut().backspace();
app.pane_mut().history_at = None;
app.pick = 0;
}
KeyCode::Left => {
app.pane_mut().left();
app.pick = 0;
}
KeyCode::Right => {
app.pane_mut().right();
app.pick = 0;
}
KeyCode::Up => app.recall(-1),
KeyCode::Down => app.recall(1),
KeyCode::PageUp => app.scroll_by(-(app.page as i16)),
KeyCode::PageDown => app.scroll_by(app.page as i16),
KeyCode::Char(c) => {
app.pane_mut().insert(c);
app.pane_mut().history_at = None;
app.pick = 0;
}
_ => {}
}
if app.mentioning().is_some() {
app.load_mentionable();
}
}
fn command_key(app: &mut App, key: KeyEvent, records_root: &Path) {
let page = app.page as i16;
match key.code {
KeyCode::Char('?') => app.open(Dialog::Keys),
KeyCode::Char('q') => leave(app),
KeyCode::Esc => {
if app.screen == Screen::Record {
to_work(app);
} else if !app.filter.is_empty() {
app.filter.clear();
app.refilter();
} else {
leave(app);
}
}
KeyCode::Char('n') | KeyCode::Enter => perform(app, Command::NewRun, records_root),
KeyCode::Char('s') => perform(app, Command::Stop, records_root),
KeyCode::Char('l') => perform(app, Command::Runs, records_root),
KeyCode::Char('w') => perform(app, Command::Repos, records_root),
KeyCode::Char('a') => perform(app, Command::Agents, records_root),
KeyCode::Char('i') => perform(app, Command::Setup, records_root),
KeyCode::Char('r') => perform(app, Command::Reload, records_root),
KeyCode::Char('p') => perform(app, Command::Promote, records_root),
KeyCode::Tab | KeyCode::Right | KeyCode::Left => {
perform(app, Command::NextDetail, records_root)
}
KeyCode::Char('t') => perform(app, Command::NewPane, records_root),
KeyCode::Char(']') => perform(app, Command::NextPane, records_root),
KeyCode::Char('X') => perform(app, Command::ClosePane, records_root),
KeyCode::Char('<') => perform(app, Command::MovePaneLeft, records_root),
KeyCode::Char('>') => perform(app, Command::MovePaneRight, records_root),
KeyCode::Char('+') => perform(app, Command::WiderPane, records_root),
KeyCode::Char('-') => perform(app, Command::NarrowerPane, records_root),
KeyCode::Char('=') => perform(app, Command::EvenPanes, records_root),
KeyCode::Char('m') | KeyCode::BackTab => perform(app, Command::Mode, records_root),
KeyCode::Char('o') => perform(app, Command::Models, records_root),
KeyCode::Char('j') | KeyCode::Down => {
app.move_by(1);
load_detail(app, records_root);
}
KeyCode::Char('k') | KeyCode::Up => {
app.move_by(-1);
load_detail(app, records_root);
}
KeyCode::Char('g') | KeyCode::Home => {
app.move_by(-(app.matching.len() as isize));
load_detail(app, records_root);
}
KeyCode::Char('G') | KeyCode::End => {
app.pane_mut().follow = true;
app.move_by(app.matching.len() as isize);
load_detail(app, records_root);
}
KeyCode::Char(' ') | KeyCode::PageDown => app.scroll_by(page),
KeyCode::Char('b') | KeyCode::PageUp => app.scroll_by(-page),
_ => {}
}
}
fn to_work(app: &mut App) {
app.screen = Screen::Work;
app.focus = Focus::Prompt;
app.pane_mut().follow = true;
app.scroll = 0;
}
fn perform(app: &mut App, command: Command, records_root: &Path) {
app.status = None;
if !Command::offered(app.situation()).contains(&command) {
app.status = Some(unavailable(app, command));
return;
}
match command {
Command::NewRun => {
app.screen = Screen::Work;
app.focus = Focus::Prompt;
}
Command::Stop => {
app.thread_mut().stop();
app.status = Some("asked the agent to stop".to_string());
}
Command::Fix => open_fix(app),
Command::Settings => {
app.project_facts = project_facts(app);
app.editing = None;
app.open(Dialog::Settings);
}
Command::Runs => app.open(Dialog::Runs),
Command::Repos => {
app.open(Dialog::Repos);
app.editing = None;
app.pick = app
.workspace
.repositories()
.iter()
.position(|r| Some(&r.name) == app.repository().map(|c| &c.name))
.unwrap_or(0);
}
Command::Prune => {
match crate::prune::leftovers(&app.workspace) {
Ok(found) => {
app.leftovers = found;
app.open(Dialog::Prune);
}
Err(e) => app.status = Some(format!("could not look: {e}")),
}
}
Command::Agents => {
app.agents = agents(&app.workspace);
app.open(Dialog::Agents);
}
Command::Fresh => {
*app.thread_mut() = thread::Thread::default();
app.status = Some("a fresh thread \u{2014} the next run starts from HEAD".to_string());
to_work(app);
}
Command::NextDetail => {
app.detail = app.detail.next();
app.scroll = 0;
load_detail(app, records_root);
}
Command::NewPane => {
app.open_pane();
to_work(app);
app.status = Some(format!("pane {} of {}", app.at + 1, app.panes.len()));
}
Command::NextPane => {
app.next_pane(1);
to_work(app);
}
Command::ClosePane => {
if let Some(why) = app.close_pane() {
app.status = Some(why);
} else {
to_work(app);
}
}
Command::MovePaneLeft | Command::MovePaneRight => {
let left = command == Command::MovePaneLeft;
if !app.move_pane(if left { -1 } else { 1 }) {
let side = if left { "left" } else { "right" };
app.status = Some(format!("this pane is already the {side}most"));
}
}
Command::WiderPane | Command::NarrowerPane => {
let wider = command == Command::WiderPane;
if !app.resize_pane(if wider { 1 } else { -1 }) {
let limit = if wider { "wide" } else { "narrow" };
app.status = Some(format!("this pane is as {limit} as it goes"));
}
}
Command::EvenPanes => app.even_panes(),
Command::Promote => app.status = Some(promote_selected(app)),
Command::Reload => match index::list(records_root) {
Ok(runs) => {
app.runs = runs;
app.refilter();
load_detail(app, records_root);
}
Err(e) => app.status = Some(format!("could not reload: {e}")),
},
Command::Setup => initialise(app, records_root),
Command::Mode => {
let next = app.thread().mode.next();
set_mode(app, next);
}
Command::Models => {
app.open(Dialog::Models);
app.models.clear();
app.models_notes.clear();
let adapters = app.workspace.adapters();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(crate::models::catalogs(&adapters));
});
app.models_loading = Some(rx);
}
Command::Keys => app.open(Dialog::Keys),
Command::Quit => leave(app),
}
}
fn set_mode(app: &mut App, mode: Mode) {
app.thread_mut().mode = mode;
app.status = Some(format!("{} \u{2014} {}", mode.word(), mode.about()));
}
fn unavailable(app: &App, command: Command) -> String {
let situation = app.situation();
match command {
Command::NewRun if situation.running => format!(
"a run is already going in this pane \u{2014} s asks it to stop, {} opens another",
label(Chord::NewPane)
),
Command::NewRun => {
"this directory cannot run anything yet \u{2014} i sets it up".to_string()
}
Command::Stop => "nothing is running in this pane".to_string(),
Command::Fix => "nothing is in the way".to_string(),
Command::NextPane | Command::ClosePane | Command::MovePaneLeft | Command::MovePaneRight => {
"this is the only pane".to_string()
}
Command::WiderPane | Command::NarrowerPane | Command::EvenPanes => {
"a width is a share of the screen \u{2014} panes share it from 160 columns".to_string()
}
Command::Setup => "this directory is already set up".to_string(),
other => format!("{} is not available here", other.name()),
}
}
fn dialog_key(app: &mut App, key: KeyEvent, records_root: &Path) {
match app.dialog {
Some(Dialog::Commands) => match key.code {
KeyCode::Esc => app.close(),
KeyCode::Enter => {
let picked = app.picked();
app.close();
if let Some(command) = picked {
perform(app, command, records_root);
}
}
KeyCode::Down => app.move_pick(1),
KeyCode::Up => app.move_pick(-1),
KeyCode::Backspace => {
app.query.pop();
app.pick = 0;
}
KeyCode::Char(c) => {
app.query.push(c);
app.pick = 0;
}
_ => {}
},
Some(Dialog::Runs) => match key.code {
KeyCode::Esc => app.close(),
KeyCode::Enter => {
if app.current().is_some() {
app.close();
app.screen = Screen::Record;
app.focus = Focus::Keys;
app.scroll = 0;
load_detail(app, records_root);
}
}
KeyCode::Down => {
app.move_by(1);
load_detail(app, records_root);
}
KeyCode::Up => {
app.move_by(-1);
load_detail(app, records_root);
}
KeyCode::Backspace => {
app.filter.pop();
app.refilter();
}
KeyCode::Char(c) => {
app.filter.push(c);
app.refilter();
}
_ => {}
},
Some(Dialog::Agents) => agents_key(app, key.code),
Some(Dialog::Models) => models_key(app, key.code),
Some(Dialog::Fallback) => fallback_key(app, key.code),
Some(Dialog::Fix) => fix_key(app, key.code),
Some(Dialog::Settings) => settings_key(app, key.code),
Some(Dialog::Repos) => repos_key(app, key.code, records_root),
Some(Dialog::Prune) => prune_key(app, key.code),
Some(Dialog::Leaving) => {
let leaving = matches!(
key.code,
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter
);
app.close();
if leaving {
depart(app);
}
}
Some(Dialog::Keys) | None => app.close(),
}
}
fn project_facts(app: &App) -> Vec<(String, String)> {
let Some(repo) = app.repository() else {
return Vec::new();
};
let Ok(config) = app.workspace.config_for(repo) else {
return Vec::new();
};
let said = |secs: Option<u64>| match secs {
Some(s) => format!("{s}s"),
None => "no ceiling".to_string(),
};
let mut facts = vec![(
"gate".to_string(),
config
.gate
.checks
.iter()
.map(|c| c.name.clone())
.collect::<Vec<_>>()
.join(", "),
)];
facts.push(("agent stops".to_string(), said(config.policy.timeout_secs)));
facts.push(("gate stops".to_string(), said(config.gate.timeout_secs)));
facts.push(("worktrees".to_string(), config.worktree.base.clone()));
facts.push((
"config".to_string(),
app.workspace
.config_source(repo)
.strip_prefix(&app.workspace.root)
.unwrap_or(&app.workspace.config_source(repo))
.display()
.to_string(),
));
facts
}
fn settings_key(app: &mut App, code: KeyCode) {
let rows = view::settings_rows(app);
if let Some(buffer) = app.editing.as_mut() {
match code {
KeyCode::Esc => app.editing = None,
KeyCode::Enter => {
let value = buffer.trim().to_string();
app.editing = None;
apply_setting(app, rows.get(app.pick).map(|r| r.0), value);
}
KeyCode::Backspace => {
buffer.pop();
}
KeyCode::Char(c) => buffer.push(c),
_ => {}
}
return;
}
match code {
KeyCode::Esc => app.close(),
KeyCode::Down => app.pick = (app.pick + 1).min(rows.len().saturating_sub(1)),
KeyCode::Up => app.pick = app.pick.saturating_sub(1),
KeyCode::Char('a') => {
app.agents = agents(&app.workspace);
app.open(Dialog::Agents);
}
KeyCode::Enter => {
if let Some((_, value, editable)) = rows.get(app.pick) {
if *editable {
app.editing = Some(if value == "\u{2014}" {
String::new()
} else {
value.clone()
});
}
}
}
_ => {}
}
}
fn apply_setting(app: &mut App, row: Option<&'static str>, value: String) {
match row {
Some("author") if !value.is_empty() => app.thread_mut().author = value,
Some("reviewer") if !value.is_empty() => app.thread_mut().reviewer = value,
Some("model") => {
app.thread_mut().model = (!value.is_empty()).then_some(value);
}
_ => {}
}
}
fn blocking(app: &App) -> Option<crate::remedy::Remedy> {
match app.repository() {
Some(repo) => crate::remedy::Remedy::diagnose(&repo.path),
None => Some(crate::remedy::Remedy::nothing_cloned(
&app.workspace.repositories_dir(),
)),
}
}
fn open_fix(app: &mut App) {
app.remedy = blocking(app);
app.blocked = app.remedy.as_ref().map(|r| r.problem.clone());
match app.remedy {
Some(_) => app.open(Dialog::Fix),
None => app.status = Some("nothing is in the way".to_string()),
}
}
fn fix_key(app: &mut App, code: KeyCode) {
let project = app
.repository()
.map(|r| r.path.clone())
.unwrap_or_else(|| app.workspace.root.clone());
let Some(remedy) = app.remedy.as_mut() else {
app.close();
return;
};
match code {
KeyCode::Esc => {
app.remedy = None;
app.close();
}
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter
if !remedy.done()
&& remedy
.steps
.get(remedy.at)
.is_some_and(|step| !step.commands.is_empty()) =>
{
remedy.take_step(&project);
if remedy.done() && !remedy.failed {
let found = app.workspace.repository(None).ok();
if found.is_some() {
app.pane_mut().repository = found;
}
app.blocked = blocking(app).map(|r| r.problem);
}
}
KeyCode::Char('s') if !remedy.done() => remedy.at += 1,
_ => {}
}
}
fn repos_key(app: &mut App, code: KeyCode, records_root: &Path) {
let repositories = app.workspace.repositories();
if let Some(name) = app.editing.as_mut() {
match code {
KeyCode::Esc => app.editing = None,
KeyCode::Enter => {
let name = name.clone();
app.editing = None;
match app.workspace.start_repository(&name) {
Ok(repo) => {
work_in(app, repo);
app.close();
}
Err(e) => app.status = Some(e.to_string()),
}
}
KeyCode::Backspace => {
name.pop();
}
KeyCode::Char(c) => name.push(c),
_ => {}
}
return;
}
match code {
KeyCode::Char('n') => app.editing = Some(String::new()),
KeyCode::Esc => app.close(),
KeyCode::Down => app.pick = (app.pick + 1).min(repositories.len().saturating_sub(1)),
KeyCode::Up => app.pick = app.pick.saturating_sub(1),
KeyCode::Enter => {
if let Some(repo) = repositories.get(app.pick).cloned() {
work_in(app, repo);
}
let _ = records_root;
app.close();
}
_ => {}
}
}
fn work_in(app: &mut App, repo: crate::workspace::Repository) {
let name = repo.name.clone();
app.pane_mut().repository = Some(repo);
*app.thread_mut() = thread::Thread::default();
app.blocked = blocking(app).map(|r| r.problem);
app.status = Some(format!("working in {name}"));
}
fn prune_key(app: &mut App, code: KeyCode) {
match code {
KeyCode::Char('y') if !app.leftovers.is_empty() => {
let (removed, failed) = crate::prune::remove(&app.leftovers);
app.leftovers.clear();
app.status = Some(match failed.first() {
Some(said) => said.clone(),
None => format!("removed {removed} worktree(s); branches and records untouched"),
});
app.close();
}
KeyCode::Esc | KeyCode::Enter => app.close(),
_ => {}
}
}
fn agents_key(app: &mut App, code: KeyCode) {
let count = app.agents.len();
match code {
KeyCode::Esc | KeyCode::Enter => app.close(),
KeyCode::Down => app.pick = (app.pick + 1).min(count.saturating_sub(1)),
KeyCode::Up => app.pick = app.pick.saturating_sub(1),
KeyCode::Char('a') => {
if let Some(id) = adopt(app) {
app.thread_mut().adapter = Some(id);
}
}
KeyCode::Char('r') => {
if let Some(id) = adopt(app) {
app.thread_mut().review_adapter = Some(id);
}
}
KeyCode::Char('x') => {
app.thread_mut().adapter = None;
app.thread_mut().review_adapter = None;
}
_ => {}
}
}
fn offer_fallback(app: &mut App, offer: session::Fallback) {
let skip = [offer.failed.clone(), offer.other.clone()];
let workspace = app.workspace.clone();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let found: Vec<Agent> = agents(&workspace)
.into_iter()
.filter(|agent| !skip.contains(&Some(agent.id.clone())))
.collect();
let _ = tx.send(found);
});
app.open(Dialog::Fallback);
app.agents.clear();
app.agents_loading = Some(rx);
app.fallback = Some(offer);
}
fn fallback_key(app: &mut App, code: KeyCode) {
let count = app.agents.len();
match code {
KeyCode::Esc => {
if let Some(offer) = app.fallback.take() {
if app.pane().prompt.trim().is_empty() {
app.pane_mut().replace(offer.task);
}
app.status =
Some("the task is back in the box \u{2014} nothing ran again".to_string());
}
app.agents_loading = None;
app.close();
}
KeyCode::Down => app.pick = (app.pick + 1).min(count.saturating_sub(1)),
KeyCode::Up => app.pick = app.pick.saturating_sub(1),
KeyCode::Enter => {
let Some(agent) = app.agents.get(app.pick) else {
return;
};
if !agent.ready {
app.status = Some(format!(
"{} did not answer either \u{2014} pick one marked ready",
agent.id
));
return;
}
if !app.pane().prompt.trim().is_empty() {
app.status = Some(
"the box holds another task \u{2014} clear it to run this one again"
.to_string(),
);
return;
}
let Some(id) = adopt(app) else {
return;
};
let Some(offer) = app.fallback.take() else {
return;
};
app.agents_loading = None;
app.close();
match offer.seat {
session::Seat::Writes => app.thread_mut().adapter = Some(id),
session::Seat::Reviews => app.thread_mut().review_adapter = Some(id),
}
let names: Vec<String> = app
.workspace
.profiles()
.unwrap_or_default()
.into_iter()
.map(|profile| profile.id)
.collect();
let (task, _, _) = mention::agents_named(&offer.task, &names);
app.pane_mut().replace(task);
start_run(app);
}
_ => {}
}
}
fn models_key(app: &mut App, code: KeyCode) {
let count = app.model_rows().len();
match code {
KeyCode::Esc => app.close(),
KeyCode::Enter => {
if let Some(row) = app.model_rows().get(app.pick).cloned() {
app.thread_mut().adapter = Some(row.profile.clone());
app.thread_mut().model = row.model.clone();
app.status = Some(format!(
"{} writes, on {}",
row.profile,
row.model.as_deref().unwrap_or("its own default model")
));
app.close();
}
}
KeyCode::Down => app.pick = (app.pick + 1).min(count.saturating_sub(1)),
KeyCode::Up => app.pick = app.pick.saturating_sub(1),
KeyCode::Backspace => {
app.query.pop();
app.pick = 0;
}
KeyCode::Char(c) => {
app.query.push(c);
app.pick = 0;
}
_ => {}
}
}
fn adopt(app: &mut App) -> Option<String> {
let agent = app.agents.get(app.pick)?;
let id = agent.id.clone();
if agent.configured {
return Some(id);
}
match crate::init::write_profile(&app.workspace, &id) {
Ok(()) => {
app.status = Some(format!("wrote adapters/{id}.toml"));
app.agents = agents(&app.workspace);
Some(id)
}
Err(e) => {
app.status = Some(format!("could not write adapters/{id}.toml: {e}"));
None
}
}
}
fn start_run(app: &mut App) {
let prompt = app.pane_mut().prompt.trim().to_string();
let planned = prompt.is_empty() && app.thread().pending_plan.is_some();
let agents: Vec<String> = app
.workspace
.profiles()
.unwrap_or_default()
.into_iter()
.map(|profile| profile.id)
.collect();
let (task, _, _) = mention::agents_named(&prompt, &agents);
if task.is_empty() && !planned {
app.status = Some("nothing to run \u{2014} write what the agent should do".to_string());
return;
}
if app.pane().running() {
app.status = Some(unavailable(app, Command::NewRun));
return;
}
if app.setup.is_some() {
app.status = Some(unavailable(app, Command::NewRun));
return;
}
if let Some(remedy) = blocking(app) {
app.blocked = Some(remedy.problem.clone());
app.remedy = Some(remedy);
app.open(Dialog::Fix);
return;
}
app.blocked = None;
app.pane_mut().replace(String::new());
app.pane_mut().history_at = None;
app.status = None;
app.pane_mut().follow = true;
app.screen = Screen::Work;
let repository = app.repository().map(|r| r.name.clone());
let workspace = app.workspace.clone();
if planned {
app.thread_mut().start_planned(workspace, repository);
} else {
app.thread_mut()
.start_naming(workspace, repository, prompt, &agents);
}
}
fn leave(app: &mut App) {
if app.leaving {
return;
}
app.open(Dialog::Leaving);
}
fn depart(app: &mut App) {
if app.anything_running() {
for pane in &mut app.panes {
pane.thread.stop();
}
app.leaving = true;
app.status = Some("stopping the run \u{2014} the browser closes when it has".to_string());
} else {
app.quit = true;
}
}
fn load_detail(app: &mut App, records_root: &Path) {
let Some(run) = app.current().map(|r| r.run_id.clone()) else {
return;
};
if app.record.is_none() {
if let Ok((record, events)) = orchestrator::replay(records_root, &run) {
app.record = Some(record);
app.events = events;
}
}
if app.detail == Detail::Diff && app.diff.is_none() {
let repo = app
.current()
.and_then(|summary| crate::promote::repository_for(&app.workspace, summary).ok());
app.diff = Some(repo.and_then(|repo| index::diff(&repo.path, &run).ok().flatten()));
}
}
fn initialise(app: &mut App, records_root: &Path) {
let Some(plan) = &app.setup else {
return;
};
match init::apply(plan, false) {
Ok(written) => {
app.status = Some(format!(
"wrote {} file(s) \u{2014} a task will run here now",
written.len()
));
app.setup = None;
app.agents = agents(&app.workspace);
if let Ok(runs) = index::list(records_root) {
app.runs = runs;
app.refilter();
}
}
Err(e) => app.status = Some(format!("could not write the project files: {e}")),
}
}
fn promote_selected(app: &App) -> String {
let Some(run) = app.current() else {
return "nothing selected".to_string();
};
if !run.approved() {
return format!("{} was not approved; nothing to promote", run.run_id);
}
let repo = match crate::promote::repository_for(&app.workspace, run) {
Ok(repo) => repo,
Err(e) => return format!("could not tell which repository: {e}"),
};
let config = match app.workspace.config_for(&repo) {
Ok(config) => config,
Err(e) => return format!("could not read the configuration: {e}"),
};
let records_root: PathBuf = app.workspace.records();
match promote::promote(&repo.path, &records_root, &run.run_id, &config, None) {
Ok(Ok(p)) => format!("promoted to {} \u{2014} nothing merged", p.branch),
Ok(Err(NotPromoted::Refused(r))) => format!("the gate refuses this run: {r:?}"),
Ok(Err(why)) => format!("not promoted \u{2014} {why}"),
Err(e) => format!("not promoted \u{2014} {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use session::Session;
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn control(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
fn chord(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
fn click(column: u16, row: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row,
modifiers: KeyModifiers::NONE,
}
}
#[test]
fn the_arrows_move_through_the_task_and_typing_lands_there() {
let root = Path::new("/p/.ostraka");
let mut a = app();
typed(&mut a, "hello world");
for _ in 0.."world".len() {
handle(&mut a, press(KeyCode::Left), root);
}
handle(&mut a, press(KeyCode::Backspace), root);
typed(&mut a, ", ");
assert_eq!(a.pane().prompt, "hello, world");
for _ in 0..20 {
handle(&mut a, press(KeyCode::Right), root);
}
typed(&mut a, "!");
assert_eq!(a.pane().prompt, "hello, world!");
}
#[test]
fn a_click_in_the_box_puts_the_cursor_on_the_character_under_it() {
let root = Path::new("/p/.ostraka");
let mut a = app();
typed(&mut a, "hello world");
handle(&mut a, press(KeyCode::Esc), root);
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 20)).expect("terminal");
terminal
.draw(|frame| view::draw(frame, &mut a))
.expect("draws");
let area = a.prompt_text;
assert_eq!(
terminal.backend().buffer()[(area.x + 5, area.y)].symbol(),
" "
);
mouse(&mut a, click(area.x + 5, area.y));
assert_eq!(a.focus, Focus::Prompt);
typed(&mut a, ",");
assert_eq!(a.pane().prompt, "hello, world");
}
fn drawn(a: &mut App, width: u16, height: u16) {
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height))
.expect("terminal");
terminal.draw(|frame| view::draw(frame, a)).expect("draws");
}
#[test]
fn a_click_on_a_column_gives_that_pane_the_keys_and_keeps_both_tasks() {
let root = Path::new("/p/.ostraka");
let mut a = app();
typed(&mut a, "left task");
handle(&mut a, chord('t'), root);
typed(&mut a, "right task");
drawn(&mut a, 200, 30);
let (index, area) = a.columns[0];
assert_eq!(index, 0);
mouse(&mut a, click(area.x + 2, area.y + 3));
assert_eq!(a.at, 0);
typed(&mut a, "!");
assert_eq!(a.pane().prompt, "left task!");
assert_eq!(a.panes[1].prompt, "right task");
}
#[test]
fn an_at_completes_a_path_and_enter_completes_before_it_runs() {
let root = Path::new("/p/.ostraka");
let mut a = app();
a.mentionable = mention::Mentionable {
loaded: true,
repository: None,
agents: vec!["codex".into()],
paths: vec!["src/".into(), "src/main.rs".into()],
};
typed(&mut a, "fix @s");
handle(&mut a, press(KeyCode::Tab), root);
assert_eq!(a.pane().prompt, "fix @src/");
typed(&mut a, "m");
handle(&mut a, press(KeyCode::Enter), root);
assert_eq!(a.pane().prompt, "fix @src/main.rs ");
assert!(
a.thread().live.is_none(),
"enter ran a task the menu was still completing"
);
}
#[test]
fn walking_back_into_a_mention_starts_its_menu_at_the_top() {
let root = Path::new("/p/.ostraka");
let mut a = app();
a.mentionable = mention::Mentionable {
loaded: true,
repository: None,
agents: vec![],
paths: vec!["src/".into(), "src/main.rs".into()],
};
typed(&mut a, "@src/ x");
a.pick = 2;
handle(&mut a, press(KeyCode::Left), root);
handle(&mut a, press(KeyCode::Left), root);
assert_eq!(a.mentioning().as_deref(), Some("src/"));
assert_eq!(a.pick, 0);
assert_eq!(
a.mention_picked().map(|c| c.text),
Some("src/main.rs".to_string())
);
}
#[test]
fn a_box_holding_only_an_agent_is_still_an_empty_task() {
let root = Path::new("/p/.ostraka");
let mut a = app();
typed(&mut a, " ");
handle(&mut a, press(KeyCode::Enter), root);
assert!(
a.status
.as_deref()
.is_some_and(|s| s.starts_with("nothing to run"))
);
assert!(a.thread().live.is_none());
}
#[test]
fn a_vendor_that_could_not_run_is_offered_another_profile_that_answers() {
use session::{Fallback, Seat};
let root = Path::new("/p/.ostraka");
let mut a = app();
a.thread_mut().offer = Some(Fallback {
seat: Seat::Writes,
failed: Some("spent".into()),
other: None,
task: "add a flag".into(),
why: "the author could not run (exit 1): insufficient credit".into(),
});
take_stock(&mut a, root);
assert_eq!(a.dialog, Some(Dialog::Fallback));
assert!(a.thread().offer.is_none(), "the offer was made twice");
a.agents_loading = None;
a.agents = vec![
Agent {
id: "down".into(),
ready: false,
note: "not installed".into(),
configured: true,
},
Agent {
id: "spare".into(),
ready: true,
note: "1.0".into(),
configured: true,
},
];
handle(&mut a, press(KeyCode::Enter), root);
assert_eq!(
a.thread().adapter,
None,
"a profile that did not answer was taken"
);
assert_eq!(a.dialog, Some(Dialog::Fallback));
handle(&mut a, press(KeyCode::Down), root);
handle(&mut a, press(KeyCode::Enter), root);
assert_eq!(a.thread().adapter.as_deref(), Some("spare"));
assert!(a.fallback.is_none());
assert_eq!(a.pane().prompt, "add a flag");
}
#[test]
fn declining_the_offer_keeps_the_task_and_the_profile() {
use session::{Fallback, Seat};
let root = Path::new("/p/.ostraka");
let mut a = app();
a.thread_mut().offer = Some(Fallback {
seat: Seat::Reviews,
failed: None,
other: Some("writer".into()),
task: "add a flag".into(),
why: "reviewer could not run (exit 1): quota exceeded".into(),
});
take_stock(&mut a, root);
a.agents_loading = None;
handle(&mut a, press(KeyCode::Esc), root);
assert_eq!(a.dialog, None);
assert_eq!(a.pane().prompt, "add a flag");
assert_eq!(a.thread().review_adapter, None);
}
#[test]
fn a_model_is_picked_with_the_profile_that_listed_it() {
use crate::models::Model;
let root = Path::new("/p/.ostraka");
let mut a = app();
a.open(Dialog::Models);
a.models = vec![
Model {
profile: "local-profile".into(),
model: None,
},
Model {
profile: "local-profile".into(),
model: Some("model-one".into()),
},
Model {
profile: "hosted-profile".into(),
model: Some("family/model-two".into()),
},
];
for c in "two".chars() {
handle(&mut a, press(KeyCode::Char(c)), root);
}
assert_eq!(a.model_rows().len(), 1, "the filter did not narrow");
handle(&mut a, press(KeyCode::Enter), root);
assert_eq!(a.dialog, None);
assert_eq!(a.thread().adapter.as_deref(), Some("hosted-profile"));
assert_eq!(a.thread().model.as_deref(), Some("family/model-two"));
a.open(Dialog::Models);
a.models = vec![Model {
profile: "local-profile".into(),
model: None,
}];
handle(&mut a, press(KeyCode::Enter), root);
assert_eq!(a.thread().adapter.as_deref(), Some("local-profile"));
assert_eq!(a.thread().model, None);
}
#[test]
fn a_mode_is_chosen_by_name_or_by_shift_tab_and_is_never_run_as_a_task() {
let root = Path::new("/p/.ostraka");
let mut a = app();
assert_eq!(a.thread().mode, Mode::Auto);
typed(&mut a, "/plan");
handle(&mut a, press(KeyCode::Enter), root);
assert_eq!(a.thread().mode, Mode::Plan);
assert!(a.pane().prompt.is_empty());
assert!(
a.thread().live.is_none(),
"the mode's name was run as a task"
);
typed(&mut a, "/ask");
handle(&mut a, press(KeyCode::Enter), root);
assert_eq!(a.thread().mode, Mode::Ask);
assert_eq!(a.focus, Focus::Prompt, "the box was not kept");
handle(&mut a, press(KeyCode::BackTab), root);
assert_eq!(a.thread().mode, Mode::Plan);
assert!(a.status.as_deref().is_some_and(|s| s.starts_with("plan")));
}
#[test]
fn enter_on_an_empty_box_says_so_unless_a_plan_is_waiting() {
let root = Path::new("/p/.ostraka");
let mut a = app();
handle(&mut a, press(KeyCode::Enter), root);
assert!(
a.status
.as_deref()
.is_some_and(|s| s.starts_with("nothing to run"))
);
assert!(a.thread().pending_plan.is_none());
}
#[test]
fn the_panes_are_arranged_from_the_keys() {
let root = Path::new("/p/.ostraka");
let mut a = app();
handle(&mut a, chord('t'), root);
assert_eq!(a.at, 1);
handle(&mut a, chord('x'), root);
handle(&mut a, press(KeyCode::Char('<')), root);
assert_eq!(a.at, 0, "the pane did not move left");
handle(&mut a, chord('x'), root);
handle(&mut a, press(KeyCode::Char('+')), root);
assert_eq!(a.pane().weight, pane::WEIGHT);
assert!(a.status.as_deref().is_some_and(|s| s.contains("160")));
drawn(&mut a, 200, 30);
handle(&mut a, chord('x'), root);
handle(&mut a, press(KeyCode::Char('+')), root);
assert_eq!(a.pane().weight, pane::WEIGHT + 1);
drawn(&mut a, 200, 30);
assert!(a.columns[0].1.width > a.columns[1].1.width);
handle(&mut a, chord('x'), root);
handle(&mut a, press(KeyCode::Char('=')), root);
assert_eq!(a.pane().weight, pane::WEIGHT);
}
#[test]
fn a_click_outside_the_box_or_under_a_dialog_moves_nothing() {
let mut a = app();
typed(&mut a, "hello");
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 20)).expect("terminal");
terminal
.draw(|frame| view::draw(frame, &mut a))
.expect("draws");
let area = a.prompt_text;
mouse(&mut a, click(area.x + 1, 0));
assert_eq!(a.pane().cursor, None);
a.open(Dialog::Keys);
mouse(&mut a, click(area.x + 1, area.y));
assert_eq!(a.pane().cursor, None, "a click reached through a dialog");
}
fn app() -> App {
App::new(Workspace::at(Path::new("/p")), Vec::new())
}
fn typed(app: &mut App, text: &str) {
for c in text.chars() {
handle(app, press(KeyCode::Char(c)), Path::new("/p/.ostraka"));
}
}
fn running(app: &mut App) {
app.thread_mut().live = Some(Session::recorded(
"a task",
vec![ostraka_runtime::progress::Step::Entered(
ostraka_runtime::progress::Phase::Authoring,
)],
None,
));
}
#[test]
fn the_box_has_the_keys_and_what_is_typed_is_the_task() {
let mut a = app();
assert_eq!(a.focus, Focus::Prompt);
typed(&mut a, "quit");
assert_eq!(a.pane().prompt, "quit");
assert!(!a.quit, "typing a command's name ran it");
}
#[test]
fn the_chords_reach_out_of_the_box() {
let mut a = app();
typed(&mut a, "half a task");
handle(&mut a, chord('k'), Path::new("/p/.ostraka"));
assert_eq!(a.dialog, Some(Dialog::Commands));
handle(&mut a, press(KeyCode::Esc), Path::new("/p/.ostraka"));
handle(&mut a, chord('x'), Path::new("/p/.ostraka"));
assert!(a.leader);
assert_eq!(a.pane().prompt, "half a task", "the chords ate the task");
}
#[test]
fn escape_hands_the_keys_back_and_keeps_the_task() {
let mut a = app();
typed(&mut a, "half a thought");
handle(&mut a, press(KeyCode::Esc), Path::new("/p/.ostraka"));
assert_eq!(a.focus, Focus::Keys);
assert_eq!(a.pane().prompt, "half a thought");
handle(&mut a, press(KeyCode::Char('n')), Path::new("/p/.ostraka"));
assert_eq!(a.focus, Focus::Prompt);
assert_eq!(a.pane().prompt, "half a thought");
}
#[test]
fn the_leader_acts_and_then_disarms() {
let mut a = app();
handle(&mut a, chord('x'), Path::new("/p/.ostraka"));
handle(&mut a, press(KeyCode::Char('q')), Path::new("/p/.ostraka"));
assert!(!a.leader, "the leader outlived the key that completed it");
assert_eq!(a.dialog, Some(Dialog::Leaving));
handle(&mut a, press(KeyCode::Char('y')), Path::new("/p/.ostraka"));
assert!(a.quit);
}
#[test]
fn leaving_is_asked_before_it_happens() {
let mut a = app();
typed(&mut a, "a task in progress");
handle(&mut a, control('c'), Path::new("/p/.ostraka"));
assert_eq!(a.dialog, Some(Dialog::Leaving));
assert!(!a.quit);
handle(&mut a, press(KeyCode::Char('n')), Path::new("/p/.ostraka"));
assert_eq!(a.dialog, None, "answering no left the question open");
assert!(!a.quit, "answering no left anyway");
assert_eq!(
a.pane().prompt,
"a task in progress",
"the task was lost by asking"
);
handle(&mut a, control('c'), Path::new("/p/.ostraka"));
handle(&mut a, press(KeyCode::Esc), Path::new("/p/.ostraka"));
assert!(!a.quit, "escape left instead of staying");
}
#[test]
fn a_leader_letter_nobody_uses_disarms_rather_than_doing_something_else() {
let mut a = app();
handle(&mut a, chord('x'), Path::new("/p/.ostraka"));
handle(&mut a, press(KeyCode::Char('z')), Path::new("/p/.ostraka"));
assert!(!a.leader);
assert!(!a.quit);
assert!(
a.pane().prompt.is_empty(),
"the leader's letter reached the box"
);
}
fn alt(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::ALT)
}
#[test]
fn a_step_the_browser_cannot_take_is_not_marked_done_by_pressing_y() {
let dir = std::env::temp_dir().join(format!("ostraka-yours-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("scratch");
let mut a = App::new(Workspace::at(&dir), Vec::new());
a.remedy = Some(crate::remedy::Remedy::nothing_cloned(&dir));
a.open(Dialog::Fix);
handle(&mut a, press(KeyCode::Char('y')), Path::new("/p/.ostraka"));
let remedy = a.remedy.as_ref().expect("a remedy");
assert_eq!(remedy.at, 0, "a step nobody took was counted as taken");
assert!(!remedy.done());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_repository_can_be_started_from_the_browser_when_there_is_nothing_to_clone() {
let dir = std::env::temp_dir().join(format!("ostraka-newrepo-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("repositories")).expect("scratch");
let mut a = App::new(Workspace::at(&dir), Vec::new());
a.open(Dialog::Repos);
handle(&mut a, press(KeyCode::Char('n')), Path::new("/p/.ostraka"));
assert_eq!(a.editing.as_deref(), Some(""), "n did not ask for a name");
typed(&mut a, "fresh");
handle(&mut a, press(KeyCode::Enter), Path::new("/p/.ostraka"));
assert!(
dir.join("repositories/fresh/.git").is_dir(),
"nothing was started"
);
assert_eq!(a.repository().map(|r| r.name.clone()), Some("fresh".into()));
assert_eq!(a.dialog, None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_name_the_browser_will_not_take_says_why_and_leaves_the_dialog_open() {
let dir = std::env::temp_dir().join(format!("ostraka-badname-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("repositories")).expect("scratch");
let mut a = App::new(Workspace::at(&dir), Vec::new());
a.open(Dialog::Repos);
handle(&mut a, press(KeyCode::Char('n')), Path::new("/p/.ostraka"));
typed(&mut a, "../escape");
handle(&mut a, press(KeyCode::Enter), Path::new("/p/.ostraka"));
assert!(a.repository().is_none());
assert!(a.status.is_some(), "it declined without saying why");
assert!(!dir.parent().expect("a parent").join("escape").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_pane_is_opened_and_moved_between_without_leaving_the_box() {
let mut a = app();
assert_eq!(a.panes.len(), 1);
handle(&mut a, chord('t'), Path::new("/p/.ostraka"));
assert_eq!(a.panes.len(), 2);
assert_eq!(a.at, 1);
handle(&mut a, chord(']'), Path::new("/p/.ostraka"));
assert_eq!(a.at, 0, "the next pane wrapped the wrong way");
handle(&mut a, chord('['), Path::new("/p/.ostraka"));
assert_eq!(a.at, 1);
}
#[test]
fn each_pane_keeps_its_own_task() {
let mut a = app();
typed(&mut a, "the first thought");
handle(&mut a, chord('t'), Path::new("/p/.ostraka"));
assert!(
a.pane().prompt.is_empty(),
"a new pane opened with the old task in it"
);
typed(&mut a, "the second");
handle(&mut a, chord(']'), Path::new("/p/.ostraka"));
assert_eq!(a.pane().prompt, "the first thought");
handle(&mut a, chord(']'), Path::new("/p/.ostraka"));
assert_eq!(a.pane().prompt, "the second");
}
#[test]
fn a_run_in_one_pane_does_not_keep_another_from_starting_one() {
let mut a = app();
running(&mut a);
handle(&mut a, chord('t'), Path::new("/p/.ostraka"));
assert!(!a.pane().running(), "the new pane inherited the run");
assert!(a.anything_running(), "the first pane's run went away");
assert!(
a.commands().contains(&Command::NewRun),
"a new run was not offered beside a run in another pane"
);
typed(&mut a, "a second task somewhere else");
handle(&mut a, press(KeyCode::Enter), Path::new("/p/.ostraka"));
assert!(
!a.status
.as_deref()
.is_some_and(|s| s.contains("already going")),
"refused because of a run in another pane: {:?}",
a.status
);
}
#[test]
fn a_pane_is_not_closed_out_from_under_a_run() {
let mut a = app();
handle(&mut a, chord('t'), Path::new("/p/.ostraka"));
running(&mut a);
a.focus = Focus::Keys;
handle(&mut a, press(KeyCode::Char('X')), Path::new("/p/.ostraka"));
assert_eq!(a.panes.len(), 2, "a running pane was closed");
assert!(
a.status.as_deref().is_some_and(|s| s.contains("running")),
"{:?}",
a.status
);
ostraka_adapter::interrupt::clear();
}
#[test]
fn the_only_pane_is_not_closed() {
let mut a = app();
a.focus = Focus::Keys;
handle(&mut a, press(KeyCode::Char('X')), Path::new("/p/.ostraka"));
assert_eq!(a.panes.len(), 1);
assert_eq!(a.status.as_deref(), Some("this is the only pane"));
}
#[test]
fn a_closed_pane_leaves_the_screen_on_one_that_is_still_there() {
let mut a = app();
handle(&mut a, chord('t'), Path::new("/p/.ostraka"));
handle(&mut a, chord('t'), Path::new("/p/.ostraka"));
assert_eq!(a.at, 2);
a.focus = Focus::Keys;
handle(&mut a, press(KeyCode::Char('X')), Path::new("/p/.ostraka"));
assert_eq!(a.panes.len(), 2);
assert_eq!(a.at, 1, "the screen landed on a pane that is gone");
let _ = alt(KeyCode::Char('1'));
}
#[test]
fn a_slash_command_typed_into_the_box_runs_the_command_not_a_task() {
let mut a = app();
typed(&mut a, "/settings");
assert!(a.slashing());
handle(&mut a, press(KeyCode::Enter), Path::new("/p/.ostraka"));
assert_eq!(a.dialog, Some(Dialog::Settings));
assert!(a.thread().live.is_none(), "a run was started for a command");
assert!(
a.pane().prompt.is_empty(),
"the command was left in the box"
);
}
#[test]
fn a_setting_typed_into_the_thread_reaches_the_next_run() {
let mut a = app();
a.open(Dialog::Settings);
handle(&mut a, press(KeyCode::Down), Path::new("/p/.ostraka"));
handle(&mut a, press(KeyCode::Enter), Path::new("/p/.ostraka"));
assert!(a.editing.is_some(), "enter did not open the row");
for _ in 0.."author".len() {
handle(&mut a, press(KeyCode::Backspace), Path::new("/p/.ostraka"));
}
typed(&mut a, "archon");
handle(&mut a, press(KeyCode::Enter), Path::new("/p/.ostraka"));
assert_eq!(a.editing, None);
assert_eq!(a.thread().author, "archon");
}
#[test]
fn an_identity_cannot_be_emptied_into_a_commit_trailer() {
let mut a = app();
a.open(Dialog::Settings);
handle(&mut a, press(KeyCode::Down), Path::new("/p/.ostraka"));
handle(&mut a, press(KeyCode::Enter), Path::new("/p/.ostraka"));
for _ in 0.."author".len() {
handle(&mut a, press(KeyCode::Backspace), Path::new("/p/.ostraka"));
}
handle(&mut a, press(KeyCode::Enter), Path::new("/p/.ostraka"));
assert_eq!(a.thread().author, "author", "an identity was emptied");
}
#[test]
fn a_chord_armed_over_a_dialog_still_completes() {
let mut a = app();
a.open(Dialog::Keys);
handle(&mut a, chord('x'), Path::new("/p/.ostraka"));
assert!(a.leader);
handle(&mut a, press(KeyCode::Char('q')), Path::new("/p/.ostraka"));
assert!(!a.leader, "the leader was swallowed and stayed armed");
assert_eq!(a.dialog, Some(Dialog::Leaving));
}
#[test]
fn a_dialog_takes_the_keys_before_anything_else_does() {
let mut a = app();
handle(&mut a, chord('x'), Path::new("/p/.ostraka"));
handle(&mut a, press(KeyCode::Char('h')), Path::new("/p/.ostraka"));
assert_eq!(a.dialog, Some(Dialog::Keys));
handle(&mut a, press(KeyCode::Char('q')), Path::new("/p/.ostraka"));
assert_eq!(a.dialog, None, "the dialog did not take the key");
assert!(!a.quit, "q reached the browser through an open dialog");
assert!(
a.pane().prompt.is_empty(),
"q reached the box through a dialog"
);
}
#[test]
fn the_palette_types_rather_than_running_commands_by_their_letters() {
let mut a = app();
handle(&mut a, chord('k'), Path::new("/p/.ostraka"));
typed(&mut a, "quit");
assert_eq!(a.query, "quit");
assert!(!a.quit, "typing a command's name ran it");
assert_eq!(a.picked(), Some(Command::Quit));
handle(&mut a, press(KeyCode::Enter), Path::new("/p/.ostraka"));
assert_eq!(
a.dialog,
Some(Dialog::Leaving),
"the palette left without asking"
);
handle(&mut a, press(KeyCode::Char('y')), Path::new("/p/.ostraka"));
assert!(a.quit);
assert_eq!(a.dialog, None);
}
#[test]
fn a_task_can_take_more_than_one_line() {
let mut a = app();
typed(&mut a, "first line");
handle(
&mut a,
KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT),
Path::new("/p/.ostraka"),
);
typed(&mut a, "second line");
assert_eq!(a.pane().prompt, "first line\nsecond line");
assert!(a.thread().live.is_none(), "a newline started the run");
}
#[test]
fn the_box_offers_back_what_has_been_asked_here() {
let mut a = app();
a.thread_mut().history = vec!["first task".into(), "second task".into()];
handle(&mut a, press(KeyCode::Up), Path::new("/p/.ostraka"));
assert_eq!(a.pane().prompt, "second task");
handle(&mut a, press(KeyCode::Up), Path::new("/p/.ostraka"));
assert_eq!(a.pane().prompt, "first task");
handle(&mut a, press(KeyCode::Down), Path::new("/p/.ostraka"));
assert_eq!(a.pane().prompt, "second task");
handle(&mut a, press(KeyCode::Down), Path::new("/p/.ostraka"));
assert!(a.pane().prompt.is_empty());
}
#[test]
fn an_empty_task_is_not_run() {
let mut a = app();
typed(&mut a, " ");
handle(&mut a, press(KeyCode::Enter), Path::new("/p/.ostraka"));
assert!(a.thread().live.is_none(), "an empty task started a run");
assert!(a.status.is_some(), "and said nothing about why not");
}
#[test]
fn a_second_run_cannot_be_started_over_a_live_one() {
let mut a = app();
running(&mut a);
typed(&mut a, "the second task");
handle(&mut a, press(KeyCode::Enter), Path::new("/p/.ostraka"));
assert_eq!(
a.thread().live.as_ref().expect("a session").prompt,
"a task",
"the live run was replaced"
);
assert!(
a.status
.as_deref()
.is_some_and(|s| s.contains("already going")),
"{:?}",
a.status
);
ostraka_adapter::interrupt::clear();
}
#[test]
fn quitting_while_a_run_is_going_stops_it_before_leaving() {
let mut a = app();
running(&mut a);
handle(&mut a, chord('t'), Path::new("/p/.ostraka"));
running(&mut a);
handle(&mut a, control('c'), Path::new("/p/.ostraka"));
handle(&mut a, press(KeyCode::Char('y')), Path::new("/p/.ostraka"));
assert!(!a.quit, "the browser left while a run was going");
assert!(a.leaving);
for (at, pane) in a.panes.iter().enumerate() {
assert!(
pane.thread.live.as_ref().expect("a session").stopping,
"pane {at}'s run was left going"
);
}
ostraka_adapter::interrupt::clear();
}
#[test]
fn stopping_when_nothing_is_running_says_so_rather_than_doing_nothing() {
let mut a = app();
a.focus = Focus::Keys;
handle(&mut a, press(KeyCode::Char('s')), Path::new("/p/.ostraka"));
assert_eq!(a.status.as_deref(), Some("nothing is running in this pane"));
}
#[test]
fn choosing_an_agent_names_it_and_x_gives_the_choice_back_to_routing() {
let mut a = app();
a.agents = vec![
view::Agent {
id: "writer".into(),
ready: true,
note: String::new(),
configured: true,
},
view::Agent {
id: "reader".into(),
ready: true,
note: String::new(),
configured: true,
},
];
a.open(Dialog::Agents);
handle(&mut a, press(KeyCode::Char('a')), Path::new("/p/.ostraka"));
assert_eq!(a.thread().adapter.as_deref(), Some("writer"));
handle(&mut a, press(KeyCode::Down), Path::new("/p/.ostraka"));
handle(&mut a, press(KeyCode::Char('r')), Path::new("/p/.ostraka"));
assert_eq!(a.thread().review_adapter.as_deref(), Some("reader"));
handle(&mut a, press(KeyCode::Char('x')), Path::new("/p/.ostraka"));
assert_eq!(a.thread().adapter, None);
assert_eq!(a.thread().review_adapter, None);
}
}