use std::cell::Cell;
use std::fmt::Write as _;
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use ftui::core::geometry::Rect;
use ftui::render::sanitize::sanitize;
use ftui::runtime::subscription::{StopSignal, SubId, Subscription, SubscriptionSender};
use ftui::text::{Text, WrapMode, display_width};
use ftui::widgets::Widget;
use ftui::widgets::paragraph::Paragraph;
use ftui::widgets::spinner::{DOTS, SpinnerState};
use ftui::widgets::textarea::TextArea;
use ftui::{Cmd, Event, Frame, KeyCode, Model, Modifiers, MouseEventKind};
use crate::ask::{AskAnswer, AskResponse, AskUiRequest, QuestionReply};
use crate::autocomplete::{AutocompleteCatalog, AutocompleteItem, AutocompleteItemKind};
use crate::extensions::{ExtensionUiRequest, ExtensionUiResponse};
use crate::interactive::{AutocompleteState, PiMsg, extension_commands_for_catalog};
use crate::interactive::{format_extension_ui_prompt, parse_extension_ui_response};
use crate::keybindings::{AppAction, KeyBinding, KeyBindings};
use std::collections::{HashMap, VecDeque};
mod info_commands;
mod plan_commands;
pub mod session_pins;
mod settings;
mod workspace_commands;
pub use settings::{SettingsKey, SettingsSnapshot};
#[derive(Debug)]
pub enum PiFtuiMsg {
Term(Event),
Agent(PiMsg),
Resumed,
Edited {
text: std::result::Result<String, String>,
width: u16,
height: u16,
},
PastedImage(Option<String>),
}
impl From<Event> for PiFtuiMsg {
fn from(event: Event) -> Self {
Self::Term(event)
}
}
const AGENT_EVENTS_SUB_ID: SubId = 0x5049_4147;
pub struct AgentEventSubscription {
rx: Arc<Mutex<Option<Receiver<PiMsg>>>>,
}
impl AgentEventSubscription {
pub fn new(rx: Receiver<PiMsg>) -> Self {
Self::from_shared(Arc::new(Mutex::new(Some(rx))))
}
const fn from_shared(rx: Arc<Mutex<Option<Receiver<PiMsg>>>>) -> Self {
Self { rx }
}
}
const AGENT_EVENT_POLL: Duration = Duration::from_millis(50);
const SPINNER_INTERVAL: Duration = Duration::from_millis(120);
const PICKER_HINT: &str = "type to filter · ↑/↓ navigate · Enter apply · Esc close";
const LOOP_STALL_BUDGET: Duration = Duration::from_millis(250);
const FRAME_BUDGET: Duration = Duration::from_hours(1);
#[must_use]
pub fn frame_budget() -> ftui::render::budget::FrameBudgetConfig {
ftui::render::budget::FrameBudgetConfig::strict(FRAME_BUDGET)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LoopPhase {
Render,
Input,
AgentEvent,
}
impl LoopPhase {
const ALL: [Self; 3] = [Self::Render, Self::Input, Self::AgentEvent];
const fn as_str(self) -> &'static str {
match self {
Self::Render => "render",
Self::Input => "input",
Self::AgentEvent => "agent_event",
}
}
}
#[derive(Debug, Default)]
struct PhaseCounters {
samples: Cell<u64>,
busy_us: Cell<u64>,
worst_us: Cell<u64>,
stalls: Cell<u64>,
}
#[derive(Debug)]
struct LoopWatchdog {
enabled: bool,
render: PhaseCounters,
input: PhaseCounters,
agent_event: PhaseCounters,
stalled: Cell<bool>,
}
impl Default for LoopWatchdog {
fn default() -> Self {
Self::new()
}
}
impl LoopWatchdog {
fn new() -> Self {
Self::with_enabled(
std::env::var_os("PI_PERF_TELEMETRY").is_some_and(|v| v == "1" || v == "true"),
)
}
fn with_enabled(enabled: bool) -> Self {
Self {
enabled,
render: PhaseCounters::default(),
input: PhaseCounters::default(),
agent_event: PhaseCounters::default(),
stalled: Cell::new(false),
}
}
const fn counters(&self, phase: LoopPhase) -> &PhaseCounters {
match phase {
LoopPhase::Render => &self.render,
LoopPhase::Input => &self.input,
LoopPhase::AgentEvent => &self.agent_event,
}
}
fn start(&self) -> Option<Instant> {
self.enabled.then(Instant::now)
}
fn finish(&self, phase: LoopPhase, started: Option<Instant>) {
let Some(started) = started else {
return;
};
let elapsed = started.elapsed();
let elapsed_us = u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX);
let counters = self.counters(phase);
counters
.samples
.set(counters.samples.get().saturating_add(1));
counters
.busy_us
.set(counters.busy_us.get().saturating_add(elapsed_us));
if elapsed_us > counters.worst_us.get() {
counters.worst_us.set(elapsed_us);
}
if elapsed < LOOP_STALL_BUDGET {
self.stalled.set(false);
return;
}
counters.stalls.set(counters.stalls.get().saturating_add(1));
if self.stalled.replace(true) {
return;
}
tracing::warn!(
schema = "pi.tui.loop_watchdog.v1",
surface = "ftui",
phase = phase.as_str(),
lag_us = elapsed_us,
budget_us = u64::try_from(LOOP_STALL_BUDGET.as_micros()).unwrap_or(u64::MAX),
"TUI event loop stalled past the lag budget"
);
}
fn snapshot(&self) -> serde_json::Value {
let phases: serde_json::Map<String, serde_json::Value> = LoopPhase::ALL
.into_iter()
.map(|phase| {
let counters = self.counters(phase);
let samples = counters.samples.get();
let busy_us = counters.busy_us.get();
(
phase.as_str().to_string(),
serde_json::json!({
"samples": samples,
"busy_us": busy_us,
"mean_us": busy_us.checked_div(samples).unwrap_or(0),
"worst_us": counters.worst_us.get(),
"stalls": counters.stalls.get(),
}),
)
})
.collect();
let stalls_total: u64 = LoopPhase::ALL
.into_iter()
.map(|phase| self.counters(phase).stalls.get())
.sum();
serde_json::json!({
"schema": "pi.tui.loop_watchdog.v1",
"surface": "ftui",
"enabled": self.enabled,
"budget_us": u64::try_from(LOOP_STALL_BUDGET.as_micros()).unwrap_or(u64::MAX),
"phases": phases,
"totals": { "stalls": stalls_total },
"verdict": if !self.enabled {
"disabled"
} else if stalls_total == 0 {
"pass"
} else {
"warn"
},
"redaction": {
"prompt_content": "omitted",
"tool_payload_content": "omitted",
"model_response_content": "omitted",
},
})
}
}
fn drain_agent_events(
rx: &Receiver<PiMsg>,
send: impl Fn(PiFtuiMsg) -> std::result::Result<(), std::sync::mpsc::SendError<PiFtuiMsg>>,
stopped: impl Fn() -> bool,
) {
loop {
if stopped() {
return;
}
match rx.recv_timeout(AGENT_EVENT_POLL) {
Ok(msg) => {
if send(PiFtuiMsg::Agent(msg)).is_err() {
return;
}
}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => {
return;
}
}
}
}
#[cfg(unix)]
fn perform_terminal_suspend(alt_screen: bool, mouse: bool) -> std::io::Result<(u16, u16)> {
release_terminal(alt_screen)?;
signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?;
reacquire_terminal(alt_screen, mouse)
}
fn release_terminal(alt_screen: bool) -> std::io::Result<()> {
use std::io::{Write, stdout};
crossterm::terminal::disable_raw_mode()?;
let mut out = stdout();
out.write_all(b"\x1b[?2004l")?; out.write_all(b"\x1b[?1006l\x1b[?1002l\x1b[?1000l")?; if alt_screen {
out.write_all(b"\x1b[?1049l")?; }
out.write_all(b"\x1b[?25h")?; out.flush()
}
fn reacquire_terminal(alt_screen: bool, mouse: bool) -> std::io::Result<(u16, u16)> {
use std::io::{Write, stdout};
crossterm::terminal::enable_raw_mode()?;
{
let mut out = stdout();
if alt_screen {
out.write_all(b"\x1b[?1049h")?; }
out.write_all(b"\x1b[?2004h")?; if mouse {
out.write_all(b"\x1b[?1000h\x1b[?1002h\x1b[?1006h")?; }
out.write_all(b"\x1b[?25l")?; out.flush()?;
}
crossterm::terminal::size()
}
fn external_editor_command() -> String {
std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| String::from("vi"))
}
fn run_external_editor(editor: &str, draft: &str) -> std::io::Result<String> {
use std::io::Write;
let mut file = tempfile::Builder::new().suffix(".md").tempfile()?;
file.write_all(draft.as_bytes())?;
file.flush()?;
let path = file.path().to_path_buf();
#[cfg(unix)]
let status = std::process::Command::new("sh")
.args(["-c", &format!("{editor} \"$1\""), "--"])
.arg(&path)
.status()?;
#[cfg(not(unix))]
let status = std::process::Command::new("cmd")
.args(["/c", &format!("{editor} \"{}\"", path.display())])
.status()?;
if !status.success() {
return Err(std::io::Error::other(format!(
"{editor} exited with {status}; the draft is unchanged"
)));
}
std::fs::read_to_string(&path)
}
fn external_editor_task(
draft: String,
alt_screen: bool,
mouse: bool,
) -> impl FnOnce() -> PiFtuiMsg + Send + 'static {
move || {
let text = release_terminal(alt_screen)
.map_err(|err| format!("external editor: {err}"))
.and_then(|()| {
run_external_editor(&external_editor_command(), &draft)
.map_err(|err| format!("external editor: {err}"))
});
let (text, (width, height)) = match reacquire_terminal(alt_screen, mouse) {
Ok(size) => (text, size),
Err(err) => (
Err(format!("external editor: terminal not restored: {err}")),
crossterm::terminal::size().unwrap_or((80, 24)),
),
};
PiFtuiMsg::Edited {
text,
width,
height,
}
}
}
#[cfg(unix)]
fn suspend_task(alt_screen: bool, mouse: bool) -> impl FnOnce() -> PiFtuiMsg + Send + 'static {
move || match perform_terminal_suspend(alt_screen, mouse) {
Ok((width, height)) => PiFtuiMsg::Term(Event::Resize { width, height }),
Err(err) => PiFtuiMsg::Agent(PiMsg::AgentError(format!("suspend/resume: {err}"))),
}
}
impl Subscription<PiFtuiMsg> for AgentEventSubscription {
fn id(&self) -> SubId {
AGENT_EVENTS_SUB_ID
}
#[allow(clippy::result_large_err)]
fn run(&self, sender: SubscriptionSender<PiFtuiMsg>, stop: StopSignal) {
let Some(rx) = self.rx.lock().ok().and_then(|mut slot| slot.take()) else {
return;
};
drain_agent_events(&rx, |message| sender.send(message), || stop.is_stopped());
}
}
#[derive(Debug, Clone, Copy)]
pub struct FtuiPalette {
accent: ftui::PackedRgba,
muted: ftui::PackedRgba,
error: ftui::PackedRgba,
warning: ftui::PackedRgba,
}
impl Default for FtuiPalette {
fn default() -> Self {
Self {
accent: ftui::PackedRgba::rgb(97, 175, 239),
muted: ftui::PackedRgba::rgb(130, 137, 151),
error: ftui::PackedRgba::rgb(220, 80, 80),
warning: ftui::PackedRgba::rgb(229, 192, 123),
}
}
}
impl FtuiPalette {
#[must_use]
pub fn from_theme(theme: &crate::theme::Theme) -> Self {
let fallback = Self::default();
let parse = |hex: &str, fallback: ftui::PackedRgba| {
crate::theme::parse_hex_color(hex)
.map_or(fallback, |(r, g, b)| ftui::PackedRgba::rgb(r, g, b))
};
Self {
accent: parse(&theme.colors.accent, fallback.accent),
muted: parse(&theme.colors.muted, fallback.muted),
error: parse(&theme.colors.error, fallback.error),
warning: parse(&theme.colors.warning, fallback.warning),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EntryRole {
User,
Assistant,
Thinking,
System,
Error,
Ask,
}
impl EntryRole {
const fn prefix(self) -> &'static str {
match self {
Self::User => "› ",
Self::System => "· ",
Self::Thinking => "∴ ",
Self::Error => "✗ ",
Self::Assistant | Self::Ask => "",
}
}
fn style(self, palette: &FtuiPalette) -> ftui::Style {
match self {
Self::User => ftui::Style::new().bold().fg(palette.accent),
Self::Assistant => ftui::Style::new(),
Self::System | Self::Ask | Self::Thinking => ftui::Style::new().dim().fg(palette.muted),
Self::Error => ftui::Style::new().bold().fg(palette.error),
}
}
}
fn last_url(transcript: &[TranscriptEntry]) -> Option<String> {
transcript.iter().rev().find_map(|entry| {
[entry.detail.as_deref(), Some(entry.text.as_str())]
.into_iter()
.flatten()
.find_map(|text| {
text.split(|c: char| c.is_whitespace() || matches!(c, '<' | '>' | '"' | '`'))
.rev()
.map(|word| word.trim_start_matches(['(', '[', '\'']))
.find(|word| word.starts_with("https://") || word.starts_with("http://"))
.map(|word| {
word.trim_end_matches(['.', ',', ';', ':', '!', '?', ')', ']', '\''])
.to_string()
})
})
})
}
fn resolve_model_selector(
available: &[String],
selector: &str,
) -> Result<(String, String, Option<crate::model::ThinkingLevel>), String> {
let selector = selector.trim();
let id_of = |full: &str| {
full.split_once('/')
.map_or(full, |(_, id)| id)
.to_ascii_lowercase()
};
let names_available_model = |candidate: &str| {
let lower = candidate.to_ascii_lowercase();
available
.iter()
.any(|full| full.to_ascii_lowercase() == lower || id_of(full) == lower)
};
let (spec, level) = match selector.rsplit_once(':') {
Some((spec, level))
if !spec.is_empty()
&& !level.bytes().all(|byte| byte.is_ascii_digit())
&& !names_available_model(selector) =>
{
level
.parse::<crate::model::ThinkingLevel>()
.map_or((selector, None), |level| (spec, Some(level)))
}
_ => (selector, None),
};
let lower = spec.to_ascii_lowercase();
let mut matches: Vec<&String> = available
.iter()
.filter(|full| full.to_ascii_lowercase() == lower || id_of(full) == lower)
.collect();
if matches.is_empty() {
matches = available
.iter()
.filter(|full| full.to_ascii_lowercase().contains(&lower))
.collect();
}
let split = |full: &str| {
full.split_once('/')
.map(|(provider, id)| (provider.to_string(), id.to_string()))
};
match matches.as_slice() {
[one] => split(one)
.map(|(provider, id)| (provider, id, level))
.ok_or_else(|| format!("malformed model entry: {one}")),
[] => match split(spec) {
Some((provider, id)) if !provider.is_empty() && !id.is_empty() => {
Ok((provider, id, level))
}
_ => Err(format!("Model not found: {spec}")),
},
many => {
let preview = many
.iter()
.take(8)
.map(|m| format!(" - {m}"))
.collect::<Vec<_>>()
.join("\n");
Err(format!(
"Ambiguous model \"{spec}\". Matches:\n{preview}\nUse provider/id for an exact match."
))
}
}
}
fn fenced_code_blocks(text: &str) -> Vec<(String, String)> {
let mut blocks = Vec::new();
let mut open: Option<(String, String, Vec<&str>)> = None;
for line in text.lines() {
let trimmed = line.trim_start();
match open.take() {
None => {
if let Some(rest) = trimmed.strip_prefix("```") {
open = Some(("```".to_string(), rest.trim().to_string(), Vec::new()));
} else if let Some(rest) = trimmed.strip_prefix("~~~") {
open = Some(("~~~".to_string(), rest.trim().to_string(), Vec::new()));
}
}
Some((fence, lang, mut lines)) => {
if trimmed.starts_with(fence.as_str())
&& trimmed.trim_start_matches(['`', '~']).trim().is_empty()
{
blocks.push((lang, lines.join("\n")));
} else {
lines.push(line);
open = Some((fence, lang, lines));
}
}
}
}
if let Some((_, lang, lines)) = open
&& !lines.is_empty()
{
blocks.push((lang, lines.join("\n")));
}
blocks
}
fn copy_choices(transcript: &[TranscriptEntry]) -> Vec<(String, String)> {
fn first_line(text: &str) -> String {
let line = text
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or("")
.trim();
let clipped: String = line.chars().take(70).collect();
if clipped.len() < line.len() {
format!("{clipped}…")
} else {
clipped
}
}
let mut choices = Vec::new();
for entry in transcript.iter().rev() {
if entry.role != EntryRole::Assistant || entry.text.trim().is_empty() {
continue;
}
for (lang, code) in fenced_code_blocks(&entry.text).into_iter().rev() {
let label = if lang.is_empty() {
String::from("code")
} else {
format!("code ({lang})")
};
choices.push((format!("{label}: {}", first_line(&code)), code));
}
choices.push((
format!("reply: {}", first_line(&entry.text)),
entry.text.clone(),
));
}
choices
}
fn open_in_browser(url: &str) -> std::io::Result<()> {
#[cfg(target_os = "macos")]
let mut command = std::process::Command::new("open");
#[cfg(target_os = "windows")]
let mut command = {
let mut command = std::process::Command::new("rundll32");
command.arg("url.dll,FileProtocolHandler");
command
};
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let mut command = std::process::Command::new("xdg-open");
command
.arg(url)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map(drop)
}
fn collapsed_thinking(text: &str) -> String {
let lines = text.lines().count();
format!("thinking · {lines} lines (ctrl+t to show)")
}
fn word_diff_parts(removed: &str, added: &str) -> Option<(String, String, String, String)> {
let rem_words: Vec<&str> = removed.split_whitespace().collect();
let add_words: Vec<&str> = added.split_whitespace().collect();
if rem_words.is_empty() || add_words.is_empty() {
return None;
}
let mut pre = 0;
while pre < rem_words.len() && pre < add_words.len() && rem_words[pre] == add_words[pre] {
pre += 1;
}
let mut suf = 0;
while suf < rem_words.len() - pre
&& suf < add_words.len() - pre
&& rem_words[rem_words.len() - 1 - suf] == add_words[add_words.len() - 1 - suf]
{
suf += 1;
}
let rem_mid = &rem_words[pre..rem_words.len() - suf];
let add_mid = &add_words[pre..add_words.len() - suf];
if rem_mid.is_empty() && add_mid.is_empty() {
return None;
}
if pre == 0 && suf == 0 {
return None;
}
let join = |words: &[&str]| words.join(" ");
let prefix = if pre > 0 {
format!("{} ", join(&rem_words[..pre]))
} else {
String::new()
};
let suffix = if suf > 0 {
format!(" {}", join(&rem_words[rem_words.len() - suf..]))
} else {
String::new()
};
Some((prefix, join(rem_mid), join(add_mid), suffix))
}
#[allow(clippy::too_many_arguments)]
const COLLAPSED_DETAIL_LINES: usize = 8;
const KEPT_DETAIL_LINES: usize = 200;
fn collapse_detail<'a>(body: &[&'a str]) -> (Vec<&'a str>, usize) {
if body.len() <= COLLAPSED_DETAIL_LINES {
return (body.to_vec(), 0);
}
let (content, already_elided) = body
.last()
.and_then(|last| {
last.strip_prefix("… +")
.and_then(|rest| rest.strip_suffix(" more lines"))
.and_then(|n| n.parse::<usize>().ok())
})
.map_or((body, 0), |n| (&body[..body.len() - 1], n));
let shown = content.len().min(COLLAPSED_DETAIL_LINES);
(
content[..shown].to_vec(),
content.len() - shown + already_elided,
)
}
#[allow(clippy::too_many_arguments)]
fn push_card_block(
lines: &mut Vec<ftui::text::Line<'static>>,
state: CardState,
text: &str,
detail: Option<&String>,
diff_styled: bool,
group_count: u32,
palette: &FtuiPalette,
spinner_frame: usize,
expanded: bool,
) {
let (glyph, style) = match state {
CardState::Pending => (
DOTS[spinner_frame % DOTS.len()],
ftui::Style::new().dim().fg(palette.accent),
),
CardState::Ok => ("✓", ftui::Style::new().fg(palette.accent)),
CardState::Err => ("✗", ftui::Style::new().bold().fg(palette.error)),
};
let head = if group_count > 1 {
format!("{glyph} {text} ×{group_count}")
} else {
format!("{glyph} {text}")
};
lines.push(ftui::text::Line::styled(head, style));
let Some(detail) = detail else {
return;
};
let dim = |s: String| ftui::text::Span::styled(s, ftui::Style::new().dim().fg(palette.muted));
let added_span = |s: String| ftui::text::Span::styled(s, ftui::Style::new().fg(palette.accent));
let removed_span =
|s: String| ftui::text::Span::styled(s, ftui::Style::new().fg(palette.error));
let all = detail.lines().collect::<Vec<_>>();
let (body, hidden) = if expanded {
(all, 0)
} else {
collapse_detail(&all)
};
let mut i = 0;
while i < body.len() {
let line = body[i];
if diff_styled
&& line.starts_with('-')
&& i + 1 < body.len()
&& body[i + 1].starts_with('+')
&& let Some((prefix, rem_mid, add_mid, suffix)) =
word_diff_parts(&line[1..], &body[i + 1][1..])
{
lines.push(ftui::text::Line::from_spans(vec![
removed_span(format!("- {prefix}")),
removed_span(rem_mid),
dim(suffix.clone()),
]));
lines.push(ftui::text::Line::from_spans(vec![
added_span(format!("+ {prefix}")),
added_span(add_mid),
dim(suffix),
]));
i += 2;
continue;
}
let span = match diff_styled.then(|| line.as_bytes().first().copied()) {
Some(Some(b'+')) => added_span(format!(" {line}")),
Some(Some(b'-')) => removed_span(format!(" {line}")),
_ => dim(format!(" {line}")),
};
lines.push(ftui::text::Line::from_spans(vec![span]));
i += 1;
}
if hidden > 0 {
lines.push(ftui::text::Line::from_spans(vec![dim(format!(
" … +{hidden} more lines (ctrl+o to expand)"
))]));
}
}
fn push_role_block(
lines: &mut Vec<ftui::text::Line<'static>>,
role: EntryRole,
content: &str,
palette: &FtuiPalette,
md: &ftui_extras::markdown::MarkdownRenderer,
) {
if role == EntryRole::Assistant {
let rendered = md.render(content);
lines.extend(rendered.lines().iter().cloned());
return;
}
let style = role.style(palette);
let prefix = role.prefix();
let indent = " ".repeat(prefix.chars().count());
for (i, line) in content.lines().enumerate() {
let lead = if i == 0 { prefix } else { indent.as_str() };
let mut rendered = String::with_capacity(lead.len() + line.len());
rendered.push_str(lead);
rendered.push_str(line);
lines.push(ftui::text::Line::styled(rendered, style));
}
if content.is_empty() {
lines.push(ftui::text::Line::styled(prefix.to_string(), style));
}
}
fn compact_line_is_boundary(
line: &ftui::text::Line<'_>,
theme: &ftui_extras::markdown::MarkdownTheme,
) -> bool {
let code_styles = [
theme.code_block,
theme.math_block,
theme.code_inline.dim(),
];
let heading_styles = [theme.h1, theme.h2, theme.h3, theme.h4, theme.h5, theme.h6];
let Some(first_style) = line.spans().first().and_then(|span| span.style) else {
return false;
};
code_styles.contains(&first_style)
|| line.spans().iter().any(|span| {
span.style
.is_some_and(|style| heading_styles.contains(&style))
})
}
fn apply_compact_spacing(
lines: &[ftui::text::Line<'static>],
theme: &ftui_extras::markdown::MarkdownTheme,
) -> Vec<ftui::text::Line<'static>> {
let mut out: Vec<ftui::text::Line<'static>> = Vec::with_capacity(lines.len());
let mut prev_boundary = false;
let mut i = 0;
while i < lines.len() {
let boundary = compact_line_is_boundary(&lines[i], theme);
let blank = !boundary && lines[i].to_plain_text().trim().is_empty();
if !blank {
out.push(lines[i].clone());
prev_boundary = boundary;
i += 1;
continue;
}
let mut j = i + 1;
while j < lines.len()
&& !compact_line_is_boundary(&lines[j], theme)
&& lines[j].to_plain_text().trim().is_empty()
{
j += 1;
}
let keep = !out.is_empty()
&& lines
.get(j)
.is_none_or(|next| prev_boundary || compact_line_is_boundary(next, theme));
if keep {
out.push(ftui::text::Line::new());
}
i = j;
}
out
}
fn leading_indent_cells(line: &ftui::text::Line<'_>) -> usize {
let mut cells = 0;
for span in line.spans() {
let content = span.as_str();
let trimmed = content.trim_start();
if trimmed.is_empty() {
cells += display_width(content);
continue;
}
cells += display_width(&content[..content.len() - trimmed.len()]);
break;
}
cells
}
fn split_leading_indent(
line: &ftui::text::Line<'static>,
indent_cells: usize,
) -> (Vec<ftui::text::Span<'static>>, ftui::text::Line<'static>) {
let mut indent: Vec<ftui::text::Span<'static>> = Vec::new();
let mut rest: Vec<ftui::text::Span<'static>> = Vec::new();
let mut consumed = 0_usize;
for span in line.spans() {
if consumed >= indent_cells {
rest.push(span.clone());
continue;
}
let span_width = span.width();
if consumed + span_width <= indent_cells {
consumed += span_width;
indent.push(span.clone());
continue;
}
let (left, right) = span.split_at_cell(indent_cells - consumed);
consumed = indent_cells;
if !left.is_empty() {
indent.push(left);
}
if !right.is_empty() {
rest.push(right);
}
}
(indent, ftui::text::Line::from_spans(rest))
}
fn hanging_indent(
line: &ftui::text::Line<'static>,
theme: &ftui_extras::markdown::MarkdownTheme,
) -> (usize, Vec<ftui::text::Span<'static>>) {
let marker_styles = [theme.list_bullet, theme.task_done, theme.task_todo];
if let Some(first) = line.spans().first()
&& !first.is_empty()
&& first
.style
.is_some_and(|style| marker_styles.contains(&style))
{
let cells = first.width();
return (cells, vec![ftui::text::Span::raw(" ".repeat(cells))]);
}
let cells = leading_indent_cells(line);
if cells == 0 {
return (0, Vec::new());
}
(cells, split_leading_indent(line, cells).0)
}
fn wrap_body_line(
line: &ftui::text::Line<'static>,
width: usize,
theme: &ftui_extras::markdown::MarkdownTheme,
) -> Vec<ftui::text::Line<'static>> {
if width == 0 || line.width() <= width {
return vec![line.clone()];
}
let (indent_cells, continuation) = hanging_indent(line, theme);
if indent_cells == 0 || indent_cells.saturating_mul(2) >= width {
return line.wrap(width, WrapMode::WordChar);
}
let (own_prefix, rest) = split_leading_indent(line, indent_cells);
rest.wrap(width - indent_cells, WrapMode::WordChar)
.into_iter()
.enumerate()
.map(|(row, piece)| {
let prefix = if row == 0 { &own_prefix } else { &continuation };
let mut out = ftui::text::Line::from_spans(prefix.iter().cloned());
for span in piece {
out.push_span(span);
}
out
})
.collect()
}
fn wrap_body_block(
lines: &mut Vec<ftui::text::Line<'static>>,
width: usize,
theme: &ftui_extras::markdown::MarkdownTheme,
) {
if width == 0 || lines.iter().all(|line| line.width() <= width) {
return;
}
let mut out: Vec<ftui::text::Line<'static>> = Vec::with_capacity(lines.len() + 8);
for line in lines.iter() {
out.extend(wrap_body_line(line, width, theme));
}
*lines = out;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CardState {
Pending,
Ok,
Err,
}
#[derive(Debug)]
struct TranscriptEntry {
role: EntryRole,
text: String,
revision: u64,
card: Option<CardState>,
pair_key: Option<String>,
detail: Option<String>,
diff_styled: bool,
tool_name: Option<String>,
group_count: u32,
}
struct ActiveAsk {
request: AskUiRequest,
question_index: usize,
answers: Vec<AskAnswer>,
}
#[derive(Debug)]
pub struct AskUiReply {
pub request_id: String,
pub response: AskResponse,
}
struct PickerOverlay {
title: String,
items: Vec<String>,
values: Vec<String>,
query: String,
shown: Vec<usize>,
selected: usize,
kind: PickerKind,
}
impl PickerOverlay {
fn new(
title: impl Into<String>,
items: Vec<String>,
values: Vec<String>,
kind: PickerKind,
) -> Self {
let shown = (0..items.len()).collect();
Self {
title: title.into(),
items,
values,
query: String::new(),
shown,
selected: 0,
kind,
}
}
fn matches(&self, index: usize) -> bool {
let query = self.query.trim();
if query.is_empty() {
return true;
}
let item = &self.items[index];
match self.kind {
PickerKind::Model => {
let id = self.values.get(index).unwrap_or(item);
crate::model_selector::full_id_matches_query(query, id)
|| (item != id && crate::model_selector::fuzzy_match(query, item))
}
PickerKind::Theme
| PickerKind::Settings
| PickerKind::SettingValue(_)
| PickerKind::Session
| PickerKind::Rewind
| PickerKind::ForkFrom
| PickerKind::Copy => crate::model_selector::fuzzy_match(query, item),
}
}
fn refilter(&mut self) {
self.shown = (0..self.items.len()).filter(|&i| self.matches(i)).collect();
self.selected = 0;
}
fn push_query_char(&mut self, ch: char) {
if ch.is_control() {
return;
}
self.query.push(ch);
self.refilter();
}
fn push_query_str(&mut self, text: &str) {
let before = self.query.len();
self.query
.extend(text.chars().filter(|ch| !ch.is_control()));
if self.query.len() != before {
self.refilter();
}
}
fn pop_query_char(&mut self) {
if self.query.pop().is_some() {
self.refilter();
}
}
fn take_choice(mut self) -> Option<String> {
let index = *self.shown.get(self.selected)?;
if self.values.is_empty() {
(index < self.items.len()).then(|| self.items.swap_remove(index))
} else {
(index < self.values.len()).then(|| self.values.swap_remove(index))
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PickerKind {
Theme,
Settings,
SettingValue(SettingsKey),
Model,
Session,
Rewind,
ForkFrom,
Copy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DoubleEscapeAction {
Rewind,
Fork,
Tree,
None,
}
impl DoubleEscapeAction {
pub fn from_setting(value: Option<&str>) -> Self {
match value.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
Some("fork") => Self::Fork,
Some("tree") => Self::Tree,
Some("none") => Self::None,
_ => Self::Rewind,
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct LoginInput(pub String);
impl std::fmt::Debug for LoginInput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("LoginInput(<redacted>)")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UiCommand {
Prompt(String),
InitialPrompt,
SetModel { provider: String, model: String },
Settings,
SetSetting { key: SettingsKey, value: String },
Bash { command: String, exclude: bool },
ResumeSession { path: String },
Compact,
Shake,
Plan { action: String },
Undo {
count: usize,
force: bool,
redo: bool,
},
Usage { refresh: bool },
Mcp {
subcommand: String,
name: Option<String>,
},
ExtensionCommand { name: String, args: String },
RegisteredExtensionCommand { name: String, args: String },
NewSession,
DeleteSession,
Login { args: String },
LoginSubmit(LoginInput),
Logout { args: String },
Fork { args: String },
Reload,
Btw {
question: String,
owner_session_id: String,
},
Fresh,
Retry,
ScopedModels { args: String },
Checkpoint { args: String },
Rewind { name: String },
Workspace {
command: workspace_commands::WorkspaceCommand,
args: String,
},
Info(info_commands::InfoCommand),
SessionInfo,
Share,
Tan(String),
TreeSummary,
SetThinking(Option<crate::model::ThinkingLevel>),
Fast(ToggleRequest),
Skillful(ToggleRequest),
MessagePicker { fork: bool },
Dump,
CopyLastCommand,
RewindTo { entry_id: String },
CycleThinking,
CycleModel { forward: bool },
SetName(String),
AddDir { dir: String },
RemoveDir { dir: String },
Crash { action: String },
Export { path: String },
}
const fn is_editor_action(action: AppAction) -> bool {
matches!(
action,
AppAction::CursorLeft
| AppAction::CursorRight
| AppAction::CursorWordLeft
| AppAction::CursorWordRight
| AppAction::CursorLineStart
| AppAction::JumpBackward
| AppAction::JumpForward
| AppAction::DeleteCharBackward
| AppAction::DeleteCharForward
| AppAction::DeleteWordBackward
| AppAction::DeleteWordForward
| AppAction::DeleteToLineStart
| AppAction::DeleteToLineEnd
| AppAction::Undo
)
}
fn apply_editor_action(input: &mut TextArea, action: AppAction) {
match action {
AppAction::CursorLeft => input.move_left(),
AppAction::CursorRight => input.move_right(),
AppAction::CursorWordLeft => input.move_word_left(),
AppAction::CursorWordRight => input.move_word_right(),
AppAction::CursorLineStart => input.move_to_line_start(),
AppAction::JumpBackward => input.move_to_document_start(),
AppAction::JumpForward => input.move_to_document_end(),
AppAction::DeleteCharBackward => input.delete_backward(),
AppAction::DeleteCharForward => input.delete_forward(),
AppAction::DeleteWordBackward => input.delete_word_backward(),
AppAction::DeleteWordForward => input.delete_word_forward(),
AppAction::DeleteToLineStart => {
for _ in 0..input.cursor().grapheme {
input.delete_backward();
}
}
AppAction::DeleteToLineEnd => input.delete_to_end_of_line(),
AppAction::Undo => input.undo(),
_ => {}
}
}
fn strip_command<'a>(input: &'a str, name: &str) -> Option<&'a str> {
if input.len() < name.len() || !input.is_char_boundary(name.len()) {
return None;
}
let (head, rest) = input.split_at(name.len());
if !head.eq_ignore_ascii_case(name) {
return None;
}
if rest.is_empty() {
Some("")
} else if rest.starts_with(' ') {
Some(rest.trim_start())
} else {
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AgentUiState {
Ready,
Working,
}
impl AgentUiState {
const fn label(self) -> &'static str {
match self {
Self::Ready => "ready",
Self::Working => "working",
}
}
}
#[allow(
clippy::struct_excessive_bools,
reason = "quit, terminal capabilities, and suspension are independent state flags"
)]
pub struct PiFtuiModel {
state: AgentUiState,
transcript: Vec<TranscriptEntry>,
displayed_session_id: Option<String>,
pins_dir: std::path::PathBuf,
streaming: String,
current_tool: Option<String>,
todo_summary: Option<String>,
error_banner: Option<String>,
login_pending: Option<(String, bool)>,
status_snapshot: Option<crate::interactive::FtuiStatusSnapshot>,
thinking: String,
spinner: SpinnerState,
usage_line: Option<String>,
palette: FtuiPalette,
picker: Option<PickerOverlay>,
settings_snapshot: Option<SettingsSnapshot>,
settings_pending: bool,
available_models: Vec<String>,
model_names: HashMap<String, String>,
pending_quit: bool,
restart_request: Option<Arc<std::sync::atomic::AtomicBool>>,
available_sessions: Vec<(String, String)>,
resume_cwd: Option<String>,
keybindings: KeyBindings,
ext_status: Option<String>,
active_ask: Option<ActiveAsk>,
active_ext: Option<ExtensionUiRequest>,
ext_queue: VecDeque<ExtensionUiRequest>,
card_draft_snapshot: Option<String>,
ext_reply_tx: Option<Sender<ExtensionUiResponse>>,
ask_reply_tx: Option<Sender<AskUiReply>>,
turn_abort: Option<TurnAbortSlot>,
turn_control: Option<TurnControlSlot>,
btw_client: Option<Arc<crate::btw::BtwClient>>,
deferred_notes: Vec<String>,
term: (u16, u16),
scroll_from_tail: usize,
rendered_total_lines: std::cell::Cell<usize>,
input: TextArea,
autocomplete: AutocompleteState,
extension_commands: Vec<crate::autocomplete::NamedEntry>,
extension_command_registry: Option<ExtensionCommandRegistry>,
submit_tx: Option<Sender<UiCommand>>,
agent_rx: Arc<Mutex<Option<Receiver<PiMsg>>>>,
alt_screen: bool,
mouse: bool,
suspending: bool,
#[cfg(test)]
suspend_task_override: Option<Box<dyn FnOnce() -> PiFtuiMsg + Send>>,
pending_task: Option<Box<dyn FnOnce() -> PiFtuiMsg + Send>>,
last_ctrl_c: Option<std::time::Instant>,
tools_expanded: bool,
show_thinking: bool,
double_escape_action: DoubleEscapeAction,
last_idle_escape: Option<Instant>,
input_history: Vec<String>,
history_cursor: Option<usize>,
history_draft: String,
file_ref_cwd: std::path::PathBuf,
steer_resources: Option<crate::resources::ResourceLoader>,
watchdog: LoopWatchdog,
transcript_revision: u64,
render_cache: std::cell::RefCell<Vec<Option<CachedBlock>>>,
render_cache_width: std::cell::Cell<u16>,
render_stats: std::cell::Cell<(usize, usize)>,
busy: Option<BusyOp>,
markdown_spacing: crate::config::MarkdownSpacing,
}
#[derive(Debug)]
struct CachedBlock {
revision: u64,
lines: Vec<ftui::text::Line<'static>>,
}
#[derive(Debug)]
struct BusyOp {
label: String,
tick_pending: bool,
}
struct Regions {
header: Rect,
body: Rect,
banner: Rect,
status: Rect,
completion: Rect,
input: Rect,
footer: Rect,
}
#[derive(Debug, Clone, Default)]
pub struct AutocompleteLaunch {
pub catalog: AutocompleteCatalog,
pub cwd: std::path::PathBuf,
pub max_visible: usize,
pub resources: Option<crate::resources::ResourceLoader>,
pub resource_source: Option<ResourceSource>,
}
#[derive(Debug, Clone)]
pub struct ResourceSource {
pub package_manager: crate::package_manager::PackageManager,
pub config: crate::config::Config,
pub cli: crate::resources::ResourceCliOptions,
}
async fn reload_driver_resources(
source: &ResourceSource,
cwd: &std::path::Path,
extension_commands: Vec<crate::autocomplete::NamedEntry>,
resources: &mut Option<crate::resources::ResourceLoader>,
catalog: &mut AutocompleteCatalog,
agent_tx: &Sender<PiMsg>,
) {
match crate::resources::ResourceLoader::load(
&source.package_manager,
cwd,
&source.config,
&source.cli,
)
.await
{
Ok(loader) => {
*catalog = AutocompleteCatalog::from_resources(&loader);
*resources = Some(loader);
}
Err(err) => {
let _ = agent_tx.send(PiMsg::System(format!(
"reload: skills and prompt templates were not re-read: {err}"
)));
}
}
catalog.extension_commands = extension_commands;
let _ = agent_tx.send(PiMsg::AutocompleteCatalog(catalog.clone()));
}
fn refresh_extension_commands(
handle: &crate::sdk::AgentSessionHandle,
catalog: &mut AutocompleteCatalog,
agent_tx: &Sender<PiMsg>,
) {
catalog.extension_commands = handle
.extension_manager()
.map(extension_commands_for_catalog)
.unwrap_or_default();
let _ = agent_tx.send(PiMsg::AutocompleteCatalog(catalog.clone()));
}
fn extension_command_name<'a>(
commands: &'a [crate::autocomplete::NamedEntry],
token: &str,
) -> Option<&'a str> {
let name = token.trim_start_matches('/').trim();
if name.is_empty() {
return None;
}
let names = || {
commands
.iter()
.map(|entry| entry.name.trim_start_matches('/').trim())
};
names()
.find(|registered| *registered == name)
.or_else(|| names().find(|registered| registered.eq_ignore_ascii_case(name)))
}
const DEFAULT_COMPLETION_ROWS: usize = 5;
const COMPLETION_HINT: &str = "↑↓ move · Tab/Enter accept · Esc dismiss";
const FIXED_CHROME_ROWS: u16 = 3;
const MAX_INPUT_ROWS: u16 = 5;
fn picker_window(selected: usize, len: usize, visible: usize) -> std::ops::Range<usize> {
if visible == 0 || len == 0 {
return 0..0;
}
let selected = selected.min(len - 1);
let max_start = len.saturating_sub(visible);
let start = selected.saturating_sub(visible - 1).min(max_start);
start..(start + visible).min(len)
}
fn layout_regions(area: Rect, input_rows: u16, banner_rows: u16, completion_rows: u16) -> Regions {
use ftui::layout::{Constraint, Flex};
let rects = Flex::vertical()
.constraints([
Constraint::Fixed(1), Constraint::Fill, Constraint::Fixed(banner_rows), Constraint::Fixed(1), Constraint::Fixed(completion_rows), Constraint::Fixed(input_rows), Constraint::Fixed(1), ])
.split(area);
Regions {
header: rects[0],
body: rects[1],
banner: rects[2],
status: rects[3],
completion: rects[4],
input: rects[5],
footer: rects[6],
}
}
impl PiFtuiModel {
pub fn new(agent_rx: Receiver<PiMsg>) -> Self {
Self {
state: AgentUiState::Ready,
transcript: Vec::new(),
displayed_session_id: None,
pins_dir: crate::config::Config::global_dir(),
deferred_notes: Vec::new(),
streaming: String::new(),
current_tool: None,
todo_summary: None,
error_banner: None,
login_pending: None,
status_snapshot: None,
thinking: String::new(),
spinner: SpinnerState::default(),
usage_line: None,
palette: FtuiPalette::default(),
picker: None,
settings_snapshot: None,
settings_pending: false,
available_models: Vec::new(),
model_names: HashMap::new(),
pending_quit: false,
restart_request: None,
available_sessions: Vec::new(),
resume_cwd: None,
keybindings: KeyBindings::default(),
ext_status: None,
active_ask: None,
active_ext: None,
ext_queue: VecDeque::new(),
card_draft_snapshot: None,
ext_reply_tx: None,
ask_reply_tx: None,
turn_abort: None,
turn_control: None,
btw_client: None,
term: (80, 24),
scroll_from_tail: 0,
rendered_total_lines: std::cell::Cell::new(0),
agent_rx: Arc::new(Mutex::new(Some(agent_rx))),
alt_screen: false,
mouse: true,
suspending: false,
watchdog: LoopWatchdog::new(),
transcript_revision: 0,
render_cache: std::cell::RefCell::new(Vec::new()),
render_cache_width: std::cell::Cell::new(0),
render_stats: std::cell::Cell::new((0, 0)),
busy: None,
markdown_spacing: crate::config::MarkdownSpacing::Comfortable,
#[cfg(test)]
suspend_task_override: None,
pending_task: None,
last_ctrl_c: None,
tools_expanded: false,
show_thinking: false,
double_escape_action: DoubleEscapeAction::Rewind,
last_idle_escape: None,
input_history: Vec::new(),
history_cursor: None,
history_draft: String::new(),
file_ref_cwd: std::path::PathBuf::from("."),
steer_resources: None,
input: TextArea::new()
.with_placeholder("Type a message (Enter to send, Alt+Enter for newline)")
.with_focus(true)
.with_soft_wrap(true),
autocomplete: {
let mut state = AutocompleteState::new(
std::path::PathBuf::from("."),
AutocompleteCatalog::default(),
);
state.max_visible = DEFAULT_COMPLETION_ROWS;
state
},
extension_commands: Vec::new(),
extension_command_registry: None,
submit_tx: None,
}
}
#[must_use]
pub fn with_autocomplete(mut self, launch: AutocompleteLaunch) -> Self {
self.file_ref_cwd.clone_from(&launch.cwd);
self.steer_resources = launch.resources;
self.autocomplete.provider.set_cwd(launch.cwd);
self.extension_commands
.clone_from(&launch.catalog.extension_commands);
self.autocomplete.provider.set_catalog(launch.catalog);
self.autocomplete.max_visible = launch.max_visible.clamp(1, 20);
self.autocomplete.close();
self
}
#[must_use]
pub fn with_submit_channel(mut self, tx: Sender<UiCommand>) -> Self {
self.submit_tx = Some(tx);
self
}
fn with_extension_command_registry(mut self, registry: ExtensionCommandRegistry) -> Self {
self.extension_command_registry = Some(registry);
self
}
fn registered_extension_command(&self, token: &str) -> Option<String> {
if let Some(registry) = &self.extension_command_registry {
let manager = registry
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()?;
let commands = extension_commands_for_catalog(&manager);
return extension_command_name(&commands, token).map(str::to_string);
}
extension_command_name(&self.extension_commands, token).map(str::to_string)
}
#[must_use]
pub const fn with_thinking_visible(mut self, visible: bool) -> Self {
self.show_thinking = visible;
self
}
#[must_use]
pub const fn with_double_escape_action(mut self, action: DoubleEscapeAction) -> Self {
self.double_escape_action = action;
self
}
#[must_use]
pub const fn with_markdown_spacing(mut self, spacing: crate::config::MarkdownSpacing) -> Self {
self.markdown_spacing = spacing;
self
}
#[must_use]
pub fn with_turn_abort(mut self, slot: TurnAbortSlot) -> Self {
self.turn_abort = Some(slot);
self
}
#[must_use]
pub fn with_turn_control(mut self, slot: TurnControlSlot) -> Self {
self.turn_control = Some(slot);
self
}
#[must_use]
pub fn with_btw_client(mut self, client: Option<Arc<crate::btw::BtwClient>>) -> Self {
self.btw_client = client;
self
}
fn live_turn_control(&self) -> Option<crate::session_control::SessionControlHandle> {
self.turn_control.as_ref().and_then(|slot| {
slot.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
})
}
#[must_use]
pub fn with_ask_reply_channel(mut self, tx: Sender<AskUiReply>) -> Self {
self.ask_reply_tx = Some(tx);
self
}
#[must_use]
pub fn with_ext_reply_channel(mut self, tx: Sender<ExtensionUiResponse>) -> Self {
self.ext_reply_tx = Some(tx);
self
}
#[must_use]
pub const fn with_palette(mut self, palette: FtuiPalette) -> Self {
self.palette = palette;
self
}
#[must_use]
pub fn with_available_models(mut self, models: Vec<String>) -> Self {
self.available_models = models;
self
}
#[must_use]
pub fn with_pins_dir(mut self, dir: std::path::PathBuf) -> Self {
self.pins_dir = dir;
self
}
#[must_use]
pub fn with_restart_request(mut self, flag: Arc<std::sync::atomic::AtomicBool>) -> Self {
self.restart_request = Some(flag);
self
}
#[must_use]
pub fn with_model_names(mut self, names: HashMap<String, String>) -> Self {
self.model_names = names;
self
}
#[must_use]
pub fn with_available_sessions(mut self, sessions: Vec<(String, String)>) -> Self {
self.available_sessions = sessions;
self
}
#[must_use]
pub fn with_resume_cwd(mut self, cwd: String) -> Self {
self.resume_cwd = Some(cwd);
self
}
#[must_use]
pub fn with_keybindings(mut self, keybindings: KeyBindings) -> Self {
self.keybindings = keybindings;
self
}
#[must_use]
pub const fn with_alt_screen(mut self, alt_screen: bool) -> Self {
self.alt_screen = alt_screen;
self
}
#[must_use]
pub const fn with_mouse_enabled(mut self, mouse: bool) -> Self {
self.mouse = mouse;
self
}
#[cfg(test)]
#[must_use]
pub fn with_suspend_task(mut self, task: impl FnOnce() -> PiFtuiMsg + Send + 'static) -> Self {
self.suspend_task_override = Some(Box::new(task));
self
}
fn input_rows(&self) -> u16 {
let lines = if self.input.is_empty() {
1
} else {
self.input.text().lines().count().max(1)
};
u16::try_from(lines)
.unwrap_or(MAX_INPUT_ROWS)
.min(MAX_INPUT_ROWS)
}
fn body_height(&self) -> usize {
let banner = u16::from(self.error_banner.is_some());
usize::from(self.term.1.saturating_sub(
FIXED_CHROME_ROWS + banner + self.input_rows() + self.completion_rows(),
))
.max(1)
}
fn completion_allowed(&self) -> bool {
self.state == AgentUiState::Ready
&& self.active_ask.is_none()
&& self.active_ext.is_none()
&& self.picker.is_none()
}
fn completion_visible(&self) -> bool {
self.autocomplete.open && !self.autocomplete.items.is_empty() && self.completion_allowed()
}
fn completion_rows(&self) -> u16 {
if !self.completion_visible() {
return 0;
}
let budget = usize::from(self.term.1 / 3).max(2);
let items = self
.autocomplete
.items
.len()
.min(self.autocomplete.max_visible)
.min(budget - 1);
u16::try_from(items + 1).unwrap_or(u16::MAX)
}
fn maybe_trigger_autocomplete(&mut self) {
if !self.completion_allowed() {
self.autocomplete.close();
return;
}
let text = self.input.text();
if !text.trim_start().starts_with('/') {
self.autocomplete.close();
return;
}
let editor = self.input.editor();
let cursor = ftui::text::CursorNavigator::new(editor.rope()).to_byte_index(editor.cursor());
let response = self.autocomplete.provider.suggest(&text, cursor);
if response
.items
.iter()
.all(|item| item.kind == AutocompleteItemKind::Path)
{
self.autocomplete.close();
return;
}
self.autocomplete.open_with(response);
}
fn accept_autocomplete(&mut self, item: &AutocompleteItem) {
let text = self.input.text();
let range = &self.autocomplete.replace_range;
let mut start = range.start.min(text.len());
while start > 0 && !text.is_char_boundary(start) {
start -= 1;
}
let mut end = range.end.min(text.len()).max(start);
while end < text.len() && !text.is_char_boundary(end) {
end += 1;
}
let mut next = String::with_capacity(text.len() + item.insert.len());
next.push_str(&text[..start]);
next.push_str(&item.insert);
next.push_str(&text[end..]);
self.input.set_text(&next);
self.input.move_to_document_end();
}
fn handle_completion_key(&mut self, key: &ftui::KeyEvent, submit: bool) -> bool {
match key.code {
KeyCode::Up => {
self.autocomplete.select_prev();
true
}
KeyCode::Down => {
self.autocomplete.select_next();
true
}
KeyCode::Tab => {
if self.autocomplete.selected.is_none() {
self.autocomplete.select_next();
}
if let Some(item) = self.autocomplete.selected_item().cloned() {
self.accept_autocomplete(&item);
}
self.autocomplete.close();
true
}
KeyCode::Escape => {
self.autocomplete.close();
true
}
_ if submit => {
if let Some(item) = self.autocomplete.selected_item().cloned() {
self.accept_autocomplete(&item);
self.autocomplete.close();
return true;
}
self.autocomplete.close();
false
}
_ => false,
}
}
fn conversation_line_count(&self) -> usize {
let transcript: usize = self
.transcript
.iter()
.map(|e| {
e.text.lines().count().max(1) + e.detail.as_ref().map_or(0, |d| d.lines().count())
})
.sum();
let streaming = if self.streaming.is_empty() {
0
} else {
self.streaming.lines().count().max(1)
};
transcript + streaming
}
const fn next_revision(&mut self) -> u64 {
self.transcript_revision += 1;
self.transcript_revision
}
fn push_entry(&mut self, role: EntryRole, text: String) {
let revision = self.next_revision();
self.transcript.push(TranscriptEntry {
role,
text,
revision,
card: None,
pair_key: None,
detail: None,
diff_styled: false,
tool_name: None,
group_count: 1,
});
}
fn insert_pasted_image(&mut self, reference: Option<String>) {
match reference {
Some(reference) => self.input.insert_text(&format!("{reference} ")),
None => {
self.error_banner = Some(String::from("No image on the clipboard to paste."));
}
}
}
fn record_history(&mut self, text: &str) {
const MAX_INPUT_HISTORY: usize = 500;
self.history_cursor = None;
self.history_draft.clear();
let text = text.trim();
if text.is_empty() || self.input_history.last().is_some_and(|last| last == text) {
return;
}
self.input_history.push(text.to_string());
if self.input_history.len() > MAX_INPUT_HISTORY {
self.input_history.remove(0);
}
}
fn history_navigable(&self) -> bool {
self.input_active()
&& self.active_ask.is_none()
&& self.active_ext.is_none()
&& (self.history_cursor.is_some() || !self.input.text().contains('\n'))
}
fn history_back(&mut self) {
let Some(newest) = self.input_history.len().checked_sub(1) else {
return;
};
let index = match self.history_cursor {
None => {
self.history_draft = self.input.text();
newest
}
Some(index) => index.saturating_sub(1),
};
self.history_cursor = Some(index);
self.input.set_text(&self.input_history[index]);
}
fn history_forward(&mut self) {
let Some(index) = self.history_cursor else {
return;
};
if index + 1 < self.input_history.len() {
self.history_cursor = Some(index + 1);
self.input.set_text(&self.input_history[index + 1]);
} else {
self.history_cursor = None;
let draft = std::mem::take(&mut self.history_draft);
self.input.set_text(&draft);
}
}
fn format_input_history(&self) -> String {
if self.input_history.is_empty() {
return String::from("No input history yet.");
}
let mut out = String::from("Input history (most recent first):");
for (n, entry) in self.input_history.iter().rev().take(50).enumerate() {
let preview: String = entry.replace('\n', "\\n").chars().take(120).collect();
let _ = write!(out, "\n {}. {preview}", n + 1);
}
out
}
fn flush_stream(&mut self) {
let thinking = std::mem::take(&mut self.thinking);
let thinking = thinking.trim();
if !thinking.is_empty() {
self.push_entry(EntryRole::Thinking, thinking.to_string());
}
if !self.streaming.is_empty() {
let text = std::mem::take(&mut self.streaming);
self.push_entry(EntryRole::Assistant, text);
}
}
fn push_tool_card(&mut self, pair_id: &str, display: &str, sanitized_name: &str) {
let revision = self.next_revision();
self.transcript.push(TranscriptEntry {
role: EntryRole::System,
text: display.to_string(),
revision,
card: Some(CardState::Pending),
pair_key: Some(pair_id.to_string()),
detail: None,
diff_styled: false,
tool_name: Some(sanitized_name.to_string()),
group_count: 1,
});
}
fn settle_pending_cards(&mut self) {
let mut revision = self.transcript_revision;
for entry in &mut self.transcript {
if entry.card == Some(CardState::Pending) {
entry.card = Some(CardState::Err);
revision += 1;
entry.revision = revision;
}
}
self.transcript_revision = revision;
}
fn finish_tool_card(
&mut self,
sanitized_pair_id: &str,
display_name: &str,
ok: bool,
sanitized_output: Option<String>,
diff_styled: bool,
) {
let pending_idx = self.transcript.iter().rposition(|e| {
e.card == Some(CardState::Pending) && e.pair_key.as_deref() == Some(sanitized_pair_id)
});
let Some(idx) = pending_idx else {
let mark = if ok { "✓" } else { "✗" };
self.push_entry(EntryRole::System, format!("{mark} {display_name}"));
return;
};
let revision = self.next_revision();
self.transcript[idx].card = Some(if ok { CardState::Ok } else { CardState::Err });
self.transcript[idx].revision = revision;
if let Some(output) = sanitized_output {
self.transcript[idx].detail = Some(output);
self.transcript[idx].diff_styled = diff_styled;
}
if ok && display_name == "read" && idx > 0 {
let prev = &self.transcript[idx - 1];
if prev.card == Some(CardState::Ok) && prev.tool_name.as_deref() == Some("read") {
let revision = self.next_revision();
self.transcript[idx - 1].group_count += 1;
self.transcript[idx - 1].text = "read".to_string();
self.transcript[idx - 1].revision = revision;
self.transcript.remove(idx);
}
}
}
fn fold_bash_detail(&mut self, sanitized_display: &str) -> bool {
const MAX_DETAIL_LINES: usize = KEPT_DETAIL_LINES;
let revision = self.next_revision();
let Some(entry) =
self.transcript.iter_mut().rev().find(|e| {
e.card == Some(CardState::Pending) && e.tool_name.as_deref() == Some("bash")
})
else {
return false;
};
let total = sanitized_display.lines().count();
let mut collected: String = sanitized_display
.lines()
.take(MAX_DETAIL_LINES)
.collect::<Vec<_>>()
.join("\n");
if total > MAX_DETAIL_LINES {
let _ = write!(collected, "\n… +{} more lines", total - MAX_DETAIL_LINES);
}
entry.detail = Some(collected);
entry.revision = revision;
true
}
fn max_scroll_from_tail(&self) -> usize {
let total = match self.rendered_total_lines.get() {
0 => self.conversation_line_count(),
rendered => rendered,
};
total.saturating_sub(self.body_height())
}
fn scroll_up(&mut self, lines: usize) {
self.scroll_from_tail = self
.scroll_from_tail
.saturating_add(lines)
.min(self.max_scroll_from_tail());
}
const fn scroll_down(&mut self, lines: usize) {
self.scroll_from_tail = self.scroll_from_tail.saturating_sub(lines);
}
#[allow(clippy::too_many_lines)]
fn handle_agent(&mut self, msg: PiMsg) -> Cmd<PiFtuiMsg> {
if self.busy.is_some()
&& !matches!(
msg,
PiMsg::AutocompleteRefresh
| PiMsg::AutocompleteCatalog(_)
| PiMsg::ToolStart { .. }
| PiMsg::ToolInvocation { .. }
| PiMsg::ToolUpdate { .. }
)
{
self.busy = None;
}
match msg {
PiMsg::AgentStart => {
self.state = AgentUiState::Working;
self.autocomplete.close();
if self.picker.as_ref().is_some_and(|picker| {
matches!(
picker.kind,
PickerKind::Settings | PickerKind::SettingValue(_)
)
}) {
self.picker = None;
}
return Cmd::tick(SPINNER_INTERVAL);
}
PiMsg::TextDelta(delta) => {
self.streaming.push_str(&sanitize(&delta));
}
PiMsg::ThinkingDelta(delta) => {
self.thinking.push_str(&sanitize(&delta));
}
PiMsg::ToolStart { name, tool_id, .. } => {
self.flush_stream();
let name = sanitize(&name).into_owned();
let pair = sanitize(&tool_id).into_owned();
self.current_tool = Some(name.clone());
self.push_tool_card(&pair, &name, &name);
}
PiMsg::ToolInvocation { tool_id, summary } => {
let pair = sanitize(&tool_id).into_owned();
let summary = sanitize(&summary).into_owned();
let revision = self.next_revision();
if let Some(entry) = self.transcript.iter_mut().rev().find(|e| {
e.card == Some(CardState::Pending)
&& e.pair_key.as_deref() == Some(pair.as_str())
}) {
entry.text = summary;
entry.revision = revision;
}
}
PiMsg::ToolEnd {
name,
tool_id,
is_error,
output,
..
} => {
let name = sanitize(&name).into_owned();
let pair = sanitize(&tool_id).into_owned();
let output = output.map(|o| sanitize(&o).into_owned());
let diff_styled = matches!(name.as_str(), "edit" | "hashline_edit");
self.finish_tool_card(&pair, &name, !is_error, output, diff_styled);
self.current_tool = None;
}
PiMsg::TodoSummary { summary } => {
self.todo_summary = summary.map(|s| sanitize(&s).into_owned());
}
PiMsg::AgentTimeCap { usage, message } => {
let command = self.handle_agent(PiMsg::AgentDone {
usage,
stop_reason: crate::model::StopReason::Stop,
error_message: None,
});
self.push_entry(EntryRole::System, sanitize(&message).into_owned());
return command;
}
PiMsg::AgentDone {
usage,
error_message,
..
} => {
self.dismiss_pending_interactions();
self.flush_stream();
if let Some(err) = error_message {
let text = sanitize(&err).into_owned();
self.push_entry(EntryRole::Error, text);
}
if let Some(usage) = usage {
self.usage_line = Some(format!(
"tokens {}↑ {}↓ · total {}",
usage.input, usage.output, usage.total_tokens
));
}
self.state = AgentUiState::Ready;
self.current_tool = None;
self.thinking.clear();
self.drain_deferred_notes();
self.settle_pending_cards();
}
PiMsg::AgentError(err) => {
self.dismiss_pending_interactions();
self.flush_stream();
self.error_banner = Some(sanitize(&err).into_owned());
self.state = AgentUiState::Ready;
self.current_tool = None;
self.thinking.clear();
self.drain_deferred_notes();
self.settle_pending_cards();
}
PiMsg::System(text) | PiMsg::SystemNote(text) => {
let text = sanitize(&text).into_owned();
self.push_entry(EntryRole::System, text);
}
PiMsg::SessionSystemNote {
owner_session_id,
message,
} => {
if self.displayed_session_id.as_deref() == Some(owner_session_id.as_str()) {
let text = sanitize(&message).into_owned();
if self.state == AgentUiState::Working {
self.deferred_notes.push(text);
} else {
self.push_entry(EntryRole::System, text);
}
}
}
PiMsg::UserInputSubmitted {
owner_session_id,
text,
history_text,
} => {
if self.displayed_session_id.as_deref() == Some(owner_session_id.as_str()) {
let text = sanitize(&text).into_owned();
self.error_banner = None;
self.record_history(&sanitize(&history_text));
self.push_entry(EntryRole::User, text);
self.scroll_from_tail = 0;
}
}
PiMsg::ConversationReset {
session_id,
messages,
status,
..
} => {
self.dismiss_pending_interactions();
self.displayed_session_id = Some(session_id);
self.apply_conversation_reset(messages, status);
}
PiMsg::RetryCommitted {
session_id,
messages,
status,
text,
..
} => {
self.dismiss_pending_interactions();
self.displayed_session_id = Some(session_id);
self.apply_conversation_reset(messages, status);
self.push_entry(EntryRole::User, sanitize(&text).into_owned());
}
PiMsg::BashResult { display, .. } => {
let text = sanitize(&display).into_owned();
if !self.fold_bash_detail(&text) {
self.push_entry(EntryRole::System, text);
}
self.current_tool = None;
self.scroll_from_tail = 0;
}
PiMsg::AskUiRequest(request) => {
if request.request.questions.is_empty() {
self.send_ask_reply(request.id, Vec::new(), true);
} else if self.active_ask.is_some() || self.active_ext.is_some() {
self.send_ask_reply(request.id, Vec::new(), true);
} else {
self.autocomplete.close();
self.capture_preexisting_card_draft();
self.push_ask_card(&request, 0);
self.active_ask = Some(ActiveAsk {
request,
question_index: 0,
answers: Vec::new(),
});
}
}
PiMsg::ExtensionUiRequest(request) => {
if !request.expects_response() {
if self.apply_extension_ui_effect(&request) {
return Cmd::none();
}
let text =
sanitize(format_extension_ui_prompt(&request).trim_end()).into_owned();
self.push_entry(EntryRole::System, text);
} else if self.answer_extension_ui_query(&request) {
return Cmd::none();
} else if self.active_ext.is_none() && self.active_ask.is_none() {
self.activate_ext_request(request);
} else {
self.ext_queue.push_back(request);
}
}
PiMsg::UiShutdown => return Cmd::quit(),
PiMsg::AutocompleteCatalog(catalog) => {
self.extension_commands
.clone_from(&catalog.extension_commands);
self.autocomplete.provider.set_catalog(catalog);
self.autocomplete.close();
}
PiMsg::TerminalTitle(title) => {
use std::io::Write as _;
let sequence = crate::delight::format_terminal_title(&title);
let mut out = std::io::stdout().lock();
let _ = out.write_all(sequence.as_bytes());
let _ = out.flush();
}
PiMsg::LoginPending {
provider,
accepts_empty_input,
} => {
self.login_pending = provider.map(|provider| (provider, accepts_empty_input));
}
PiMsg::StatusSnapshot(snapshot) => {
self.status_snapshot = Some(snapshot);
}
PiMsg::Settings { snapshot, changed } => {
self.settings_pending = false;
match changed {
Some(SettingsKey::HideThinking) => {
self.show_thinking = !snapshot.hide_thinking;
}
Some(SettingsKey::MarkdownSpacing) => {
self.markdown_spacing = snapshot.markdown_spacing;
}
Some(SettingsKey::DoubleEscape) => {
self.double_escape_action = snapshot.double_escape;
self.last_idle_escape = None;
}
Some(SettingsKey::AutocompleteRows) => {
self.autocomplete.max_visible = snapshot.autocomplete_rows;
self.autocomplete.close();
}
_ => {}
}
if matches!(
changed,
Some(SettingsKey::HideThinking | SettingsKey::MarkdownSpacing)
) {
self.render_cache.borrow_mut().clear();
}
self.settings_snapshot = Some(snapshot);
if self.state == AgentUiState::Ready
&& self.active_ask.is_none()
&& self.active_ext.is_none()
&& self.login_pending.is_none()
{
self.open_settings_picker();
}
}
PiMsg::MessagePicker { fork, messages } => {
if messages.is_empty() {
self.push_entry(
EntryRole::System,
String::from("No messages to branch from"),
);
} else {
let (items, values) = messages
.iter()
.enumerate()
.rev()
.map(|(n, (summary, id))| {
(format!("{}. {}", n + 1, sanitize(summary)), id.clone())
})
.unzip();
let (title, kind) = if fork {
(
"Fork a new session from (Enter to fork, Esc to close)",
PickerKind::ForkFrom,
)
} else {
(
"Rewind to before (Enter: edit and resend it; the old path stays as a branch)",
PickerKind::Rewind,
)
};
self.picker = Some(PickerOverlay::new(title, items, values, kind));
}
}
PiMsg::SetEditorText {
owner_session_id,
text,
} if self
.displayed_session_id
.as_deref()
.is_none_or(|shown| shown == owner_session_id) =>
{
self.input.set_text(&text);
}
PiMsg::RestorePendingInput {
owner_session_id,
text,
} if self
.displayed_session_id
.as_deref()
.is_none_or(|shown| shown == owner_session_id)
&& !text.is_empty() =>
{
let draft = self.input.text();
let restored = if draft.is_empty() {
text
} else {
format!("{text}\n\n{draft}")
};
self.input.set_text(&restored);
}
_ => {}
}
Cmd::none()
}
fn push_ask_card(&mut self, request: &AskUiRequest, index: usize) {
let total = request.request.questions.len();
let card =
crate::ask::format_question_card(&request.request.questions[index], index, total);
let text = sanitize(card.trim_end()).into_owned();
self.push_entry(EntryRole::Ask, text);
self.scroll_from_tail = 0;
}
fn send_ask_reply(&self, request_id: String, answers: Vec<AskAnswer>, dismissed: bool) {
if let Some(tx) = &self.ask_reply_tx {
let _ = tx.send(AskUiReply {
request_id,
response: AskResponse { answers, dismissed },
});
}
}
fn submit_ask_answer(&mut self) {
let Some(mut ask) = self.active_ask.take() else {
return;
};
let raw = self.input.text();
self.input.set_text("");
let index = ask.question_index;
let question = &ask.request.request.questions[index];
match crate::ask::parse_question_reply(question, &raw) {
Err(err) => {
let text = format!(" ! {}", sanitize(&err));
self.push_entry(EntryRole::Ask, text);
self.scroll_from_tail = 0;
self.active_ask = Some(ask); }
Ok(QuestionReply::Cancel) => {
self.push_entry(EntryRole::Ask, String::from(" (dismissed)"));
self.scroll_from_tail = 0;
self.send_ask_reply(ask.request.id, Vec::new(), true);
self.maybe_activate_queued_ext();
}
Ok(reply) => {
let (selected, other) = match reply {
QuestionReply::Selected(labels) => (labels, None),
QuestionReply::Other(text) => (Vec::new(), Some(text)),
QuestionReply::Cancel => unreachable!("handled above"),
};
let echo = other.as_ref().map_or_else(
|| format!(" → {}", selected.join(", ")),
|text| format!(" → {text}"),
);
let echo = sanitize(&echo).into_owned();
self.push_entry(EntryRole::Ask, echo);
let question_id = question.id.clone().unwrap_or_else(|| index.to_string());
ask.answers.push(AskAnswer {
question_id,
selected,
other,
});
let next = index + 1;
if next < ask.request.request.questions.len() {
self.push_ask_card(&ask.request, next);
ask.question_index = next;
self.active_ask = Some(ask);
} else {
self.scroll_from_tail = 0;
self.send_ask_reply(ask.request.id, ask.answers, false);
self.maybe_activate_queued_ext();
}
}
}
}
fn submit_mid_turn(&mut self, follow_up: bool) {
let text = self.input.text();
let trimmed = text.trim();
if trimmed.is_empty() {
return;
}
let clean = sanitize(trimmed).into_owned();
let token = clean.split_whitespace().next().unwrap_or("");
if token.starts_with('/') && self.registered_extension_command(token).is_some() {
self.push_entry(
EntryRole::Error,
String::from(
"Extension commands wait until the agent finishes; press Escape to abort it.",
),
);
return;
}
if let Some(question) = strip_command(&clean, "/btw") {
let question = question.trim().to_string();
self.queue_btw(&question);
return;
}
if let Some(message) = strip_command(&clean, "/queue") {
let message = message.trim().to_string();
if message.is_empty() {
self.push_entry(EntryRole::Error, String::from("Usage: /queue <message>"));
return;
}
self.input.set_text(&message);
self.submit_mid_turn(true);
return;
}
if clean.starts_with('/') || clean.starts_with('!') {
self.push_entry(
EntryRole::Error,
String::from("Commands wait until the agent finishes; press Escape to abort it."),
);
return;
}
let steer_text = match prepare_prompt(
&clean,
self.steer_resources.as_ref(),
&self.file_ref_cwd,
None,
true,
) {
Ok((_, images)) if !images.is_empty() => {
self.push_entry(
EntryRole::Error,
String::from(
"Attachments send once the agent finishes; the message is kept in the editor.",
),
);
return;
}
Ok((text, _)) => text,
Err(err) => {
self.push_entry(EntryRole::Error, format!("Not sent: {err}"));
return;
}
};
self.record_history(&clean);
self.input.set_text("");
self.autocomplete.close();
self.scroll_from_tail = 0;
let Some(control) = self.live_turn_control() else {
self.push_entry(EntryRole::User, clean.clone());
self.send_command(UiCommand::Prompt(clean));
return;
};
let queued = if follow_up {
control.follow_up(&steer_text)
} else {
control.steer(&steer_text)
};
match queued {
Ok(_) => {
let label = if follow_up { "follow-up" } else { "steer" };
self.push_entry(EntryRole::User, format!("({label}) {clean}"));
}
Err(err) => {
self.input.set_text(&clean);
self.push_entry(EntryRole::Error, err.to_string());
}
}
}
fn queue_btw(&mut self, question: &str) {
if question.is_empty() {
self.push_entry(EntryRole::Error, String::from(BTW_USAGE));
return;
}
if self.btw_client.is_none() {
self.push_entry(EntryRole::Error, String::from(BTW_UNAVAILABLE));
return;
}
if question.len()
> crate::text_completion::MAX_INPUT_BYTES
.saturating_sub(crate::btw::BTW_SYSTEM_PROMPT.len())
{
self.push_entry(
EntryRole::Error,
String::from("/btw refused: question exceeds the auxiliary privacy scan budget"),
);
return;
}
let Some(owner_session_id) = self.displayed_session_id.clone() else {
self.push_entry(
EntryRole::Error,
String::from("/btw: session is still loading; try again when it is ready"),
);
return;
};
self.input.set_text("");
let note = if self.state == AgentUiState::Working {
format!("(/btw) {question} — queued until the current turn finishes")
} else {
format!("(/btw) {question}")
};
self.push_entry(EntryRole::System, note);
self.send_command(UiCommand::Btw {
question: question.to_string(),
owner_session_id,
});
}
fn restore_queued_input(&mut self) {
let Some(control) = self.live_turn_control() else {
return;
};
let pending = control.take_pending();
if pending.is_empty() {
self.push_entry(
EntryRole::System,
String::from("No queued messages to restore."),
);
return;
}
let count = pending.len();
let mut text = pending
.into_iter()
.map(|input| input.text)
.collect::<Vec<_>>()
.join("\n\n");
let typed = self.input.text();
if !typed.trim().is_empty() {
text.push_str("\n\n");
text.push_str(&typed);
}
self.input.set_text(&text);
self.push_entry(
EntryRole::System,
format!("Restored {count} queued message(s) to the editor."),
);
}
fn submit_input(&mut self) {
if self.settings_pending {
return;
}
let text = self.input.text();
let trimmed = text.trim();
if let Some((provider, accepts_empty_input)) = self.login_pending.clone() {
if trimmed.starts_with('/') {
self.login_pending = None;
self.push_entry(EntryRole::System, format!("{provider} login cancelled."));
} else {
if trimmed.is_empty() && !accepts_empty_input {
return;
}
let secret = trimmed.to_string();
self.input.set_text("");
self.autocomplete.close();
self.error_banner = None;
self.scroll_from_tail = 0;
self.push_entry(
EntryRole::System,
format!("(login input for {provider} submitted)"),
);
self.begin_busy(format!("completing {provider} login ..."));
self.send_command(UiCommand::LoginSubmit(LoginInput(secret)));
return;
}
}
if trimmed.is_empty() {
return;
}
let clean = sanitize(trimmed).into_owned();
if self.busy.is_some()
&& clean.starts_with('/')
&& self
.registered_extension_command(clean.split_whitespace().next().unwrap_or(""))
.is_some()
{
self.push_entry(
EntryRole::Error,
String::from("Extension commands wait until the current operation finishes."),
);
return;
}
if self.state == AgentUiState::Working {
self.submit_mid_turn(false);
return;
}
self.error_banner = None;
self.record_history(&clean);
self.input.set_text("");
self.autocomplete.close();
self.scroll_from_tail = 0;
self.push_entry(EntryRole::User, clean.clone());
let bang = clean
.strip_prefix("!!")
.map(|rest| (rest.trim(), true))
.or_else(|| clean.strip_prefix('!').map(|rest| (rest.trim(), false)));
if let Some((command, exclude)) = bang {
if command.is_empty() {
self.push_entry(EntryRole::Error, String::from("usage: !<command>"));
} else {
self.send_command(UiCommand::Bash {
command: command.to_string(),
exclude,
});
}
return;
}
if clean.starts_with('/') && self.route_slash_command(&clean) {
return;
}
self.send_command(UiCommand::Prompt(clean));
}
fn route_slash_command(&mut self, clean: &str) -> bool {
let (token, rest) = clean.split_once(char::is_whitespace).unwrap_or((clean, ""));
if let Some(name) = self.registered_extension_command(token) {
self.begin_busy(format!("running /{name} ..."));
self.send_command(UiCommand::RegisteredExtensionCommand {
name,
args: rest.trim().to_string(),
});
return true;
}
if token.eq_ignore_ascii_case("/model")
|| token.eq_ignore_ascii_case("/m")
|| token.eq_ignore_ascii_case("/switch")
{
self.route_model_command(rest.trim());
return true;
}
self.route_slash_command_tail(clean)
}
fn route_model_command(&mut self, spec: &str) {
{
if spec.is_empty() {
if self.available_models.is_empty() {
self.push_entry(
EntryRole::Error,
String::from("no models available; use /model <provider>/<model>"),
);
} else {
let rows = self
.available_models
.iter()
.map(|id| match self.model_names.get(id) {
Some(name) if !name.trim().is_empty() && name != id => {
format!("{id} · {}", sanitize(name.trim()))
}
_ => id.clone(),
})
.collect();
self.picker = Some(PickerOverlay::new(
"Model (Enter to switch, Esc to close)",
rows,
self.available_models.clone(),
PickerKind::Model,
));
}
} else {
match resolve_model_selector(&self.available_models, spec) {
Ok((provider, model, level)) => {
let target = format!("{provider}/{model}");
self.push_entry(
EntryRole::System,
format!("switching model to {target} ..."),
);
self.begin_busy(format!("switching model to {target} ..."));
self.send_command(UiCommand::SetModel { provider, model });
if let Some(level) = level {
self.send_command(UiCommand::SetThinking(Some(level)));
}
}
Err(message) => self.push_entry(EntryRole::Error, message),
}
}
}
}
fn route_undo_command(&mut self, args: &str, redo: bool) -> bool {
let verb = if redo { "redo" } else { "undo" };
let mut count = 1_usize;
let mut force = false;
for token in args.split_whitespace() {
if token.eq_ignore_ascii_case("force") {
force = true;
} else if let Ok(n) = token.parse::<usize>() {
count = n.max(1);
} else {
self.push_entry(EntryRole::Error, format!("usage: /{verb} [n] [force]")); return true;
}
}
self.send_command(UiCommand::Undo { count, force, redo });
true
}
#[allow(clippy::too_many_lines)]
fn route_slash_command_tail(&mut self, clean: &str) -> bool {
let canon = clean.to_ascii_lowercase();
if canon == "/restart" {
if self.state == AgentUiState::Working {
self.push_entry(
EntryRole::Error,
String::from(
"Wait for the turn to finish (or Esc to stop it) before restarting",
),
);
} else if let Some(flag) = &self.restart_request {
flag.store(true, std::sync::atomic::Ordering::SeqCst);
self.pending_quit = true;
} else {
self.push_entry(
EntryRole::Error,
String::from("/restart is not available here"),
);
}
return true;
}
if canon == "/exit" || canon == "/quit" || canon == "/q" {
self.pending_quit = true;
return true;
}
if let Some(args) = strip_command(clean, "/settings") {
if args.trim().is_empty() {
self.request_settings();
} else {
self.push_entry(EntryRole::Error, String::from("Usage: /settings"));
}
return true;
}
if canon == "/plan-review" {
self.begin_busy("updating plan ...");
self.send_command(UiCommand::Plan {
action: String::from("review"),
});
return true;
}
if let Some(command) = canon
.split_whitespace()
.next()
.and_then(info_commands::InfoCommand::parse)
{
self.send_command(UiCommand::Info(command));
return true;
}
let (token, rest) = clean.split_once(char::is_whitespace).unwrap_or((clean, ""));
if let Some(command) = workspace_commands::WorkspaceCommand::parse(token) {
self.begin_busy(format!("{} ...", token.to_ascii_lowercase()));
self.send_command(UiCommand::Workspace {
command,
args: rest.trim().to_string(),
});
return true;
}
if let Some(rest) = strip_command(clean, "/plan") {
if plan_commands::parse(rest).is_ok() {
self.begin_busy("updating plan ...");
self.send_command(UiCommand::Plan {
action: rest.trim().to_string(),
});
} else {
self.push_entry(EntryRole::Error, plan_commands::USAGE.to_string());
}
return true;
}
if let Some(rest) = strip_command(clean, "/add-dir") {
self.push_entry(
EntryRole::System,
format!("adding workspace root {} ...", rest.trim()),
);
self.send_command(UiCommand::AddDir {
dir: rest.trim().to_string(),
});
return true;
}
if let Some(rest) = strip_command(clean, "/remove-dir") {
self.push_entry(
EntryRole::System,
format!("removing workspace root {} ...", rest.trim()),
);
self.send_command(UiCommand::RemoveDir {
dir: rest.trim().to_string(),
});
return true;
}
if canon == "/changelog" {
self.push_entry(
EntryRole::System,
crate::embedded_assets::changelog().to_string(),
);
self.scroll_from_tail = 0;
return true;
}
if let Some(arg) = strip_command(clean, "/copy") {
self.scroll_from_tail = 0;
let copy = |this: &mut Self, text: Option<String>, missing: &str| match text {
Some(text) => {
let outcome = crate::interactive::copy_text_to_clipboard(&text);
this.push_entry(EntryRole::System, outcome);
}
None => this.push_entry(EntryRole::Error, missing.to_string()),
};
match arg.trim().to_ascii_lowercase().as_str() {
"" => {
let (items, values): (Vec<_>, Vec<_>) =
copy_choices(&self.transcript).into_iter().take(100).unzip();
if items.is_empty() {
self.push_entry(
EntryRole::Error,
String::from("No agent messages to copy yet."),
);
} else {
self.picker = Some(PickerOverlay::new(
"Copy (Enter to copy, Esc to close)",
items,
values,
PickerKind::Copy,
));
}
}
"code" => {
let code = self
.transcript
.iter()
.rev()
.filter(|entry| entry.role == EntryRole::Assistant)
.find_map(|entry| fenced_code_blocks(&entry.text).pop())
.map(|(_, code)| code);
copy(self, code, "No code block to copy.");
}
"link" | "url" => {
let link = last_url(&self.transcript);
copy(self, link, "No link to copy.");
}
"cmd" | "command" => self.send_command(UiCommand::CopyLastCommand),
_ => self.push_entry(
EntryRole::Error,
String::from("Usage: /copy [code|cmd|link]"),
),
}
return true;
}
if let Some(rest) = strip_command(clean, "/export") {
self.begin_busy(String::from("exporting ..."));
self.send_command(UiCommand::Export {
path: rest.trim().to_string(),
});
return true;
}
if let Some(rest) = strip_command(clean, "/crash") {
self.send_command(UiCommand::Crash {
action: rest.trim().to_ascii_lowercase(),
});
return true;
}
let canon = clean.to_ascii_lowercase();
if canon == "/compact" {
self.push_entry(
EntryRole::System,
String::from("compacting conversation ..."),
);
self.begin_busy("compacting conversation ...");
self.send_command(UiCommand::Compact);
return true;
}
if canon == "/shake" || canon == "/compact shake" {
self.begin_busy("shaking conversation ...");
self.send_command(UiCommand::Shake);
return true;
}
if let Some(rest) = strip_command(clean, "/undo") {
return self.route_undo_command(rest, false);
}
if let Some(rest) = strip_command(clean, "/redo") {
return self.route_undo_command(rest, true);
}
if let Some(rest) = strip_command(clean, "/usage") {
let refresh = rest.trim().eq_ignore_ascii_case("refresh");
self.push_entry(
EntryRole::System,
String::from("fetching provider usage ..."),
);
self.begin_busy("fetching provider usage ...");
self.send_command(UiCommand::Usage { refresh });
return true;
}
if let Some(rest) = strip_command(clean, "/mcp") {
let mut parts = rest.split_whitespace();
let subcommand = parts.next().unwrap_or("list").to_ascii_lowercase();
let name = parts.next().map(str::to_string);
let valid = match subcommand.as_str() {
"list" => name.is_none() && parts.next().is_none(),
"trust" | "deny" | "test" => name.is_some() && parts.next().is_none(),
_ => false,
};
if valid {
self.send_command(UiCommand::Mcp { subcommand, name });
} else {
self.push_entry(
EntryRole::Error,
String::from("usage: /mcp [list|trust <name>|deny <name>|test <name>]"),
);
}
return true;
}
if canon == "/theme" {
self.picker = Some(PickerOverlay::new(
"Theme (Enter to apply, Esc to close)",
vec![String::from("dark"), String::from("light")],
Vec::new(),
PickerKind::Theme,
));
return true;
}
if canon == "/resume" || canon == "/r" {
if let Some(cwd) = &self.resume_cwd {
self.available_sessions = session_pins::resume_entries(cwd, &self.pins_dir);
}
if self.available_sessions.is_empty() {
self.push_entry(EntryRole::Error, String::from("no saved sessions found"));
} else {
let (items, values) = self
.available_sessions
.iter()
.map(|(label, path)| (label.clone(), path.clone()))
.unzip();
self.picker = Some(PickerOverlay::new(
"Resume session (Enter to load, Esc to close)",
items,
values,
PickerKind::Session,
));
}
return true;
}
if canon == "/hotkeys" || canon == "/keys" || canon == "/keybindings" {
self.push_entry(
EntryRole::System,
crate::keybindings::format_hotkeys_for_ftui(&self.keybindings),
);
return true;
}
if canon == "/help" || canon == "/h" || canon == "/?" {
self.push_entry(
EntryRole::System,
String::from(
"pi commands: /model or /switch [model[:level]], /queue <message>, /resume, /new, \
/session, /name <name>, /plan, /compact, /tree, /undo [n], /redo [n], \
/export [path], /copy [code|cmd|link], /dump, /pin, /delete, /share, /tan <task>, /usage, /mcp, \
/add-dir <dir>, /remove-dir <dir>, /crash [list|show|delete], \
/thinking [level], /fast [on|off|status], /skillful [on|off|status], /branch (or Esc Esc), /theme, /changelog, /clear, /hotkeys, \
/login [provider], /logout [provider], /fork [n|id|list], /reload, \
/rename <name>, /plan-review, /btw <question>, /tools, /extensions, \
/skills, /dirs, /history, /fresh, /retry, /shake, /checkpoint [name], /rewind [name], /rules, /omfg <complaint>, \
/commit [--dry-run], /review [target], /handoff, /approval [mode], \
/advisor [on|off|status], /memory [view|list|search|forget], /hub [id], \
/security [paths], /plugins, /open, /reload-plugins, \
/scoped-models [patterns|clear], /template [name args], /templates, /ssh, \
/context, /todo, /jobs, /stats, /settings (F2), /help, \
/restart, /exit, !<cmd> (runs + sends output to the agent), !!<cmd> \
(display-only)",
),
);
return true;
}
let (cmd_name, cmd_args) = clean.split_once(char::is_whitespace).unwrap_or((clean, ""));
match cmd_name.to_ascii_lowercase().as_str() {
"/new" => {
self.begin_busy("starting new session ...");
self.send_command(UiCommand::NewSession);
return true;
}
"/clear" | "/cls" => {
self.transcript.clear();
self.render_cache.borrow_mut().clear();
self.streaming.clear();
self.thinking.clear();
self.current_tool = None;
self.scroll_from_tail = 0;
self.push_entry(EntryRole::System, String::from("Conversation cleared"));
return true;
}
"/session" | "/info" => {
self.send_command(UiCommand::SessionInfo);
return true;
}
"/history" | "/hist" => {
let listing = self.format_input_history();
self.push_entry(EntryRole::System, listing);
return true;
}
"/fresh" => {
self.send_command(UiCommand::Fresh);
return true;
}
"/retry" => {
self.begin_busy("retrying last turn ...");
self.send_command(UiCommand::Retry);
return true;
}
"/template" => {
let rest = cmd_args.trim();
if rest.is_empty() {
self.send_command(UiCommand::Info(info_commands::InfoCommand::Templates));
} else {
let (name, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
let name = name.trim_start_matches('/');
self.begin_busy(format!("running /{name} ..."));
self.send_command(UiCommand::ExtensionCommand {
name: name.to_string(),
args: args.trim().to_string(),
});
}
return true;
}
"/scoped-models" => {
self.send_command(UiCommand::ScopedModels {
args: cmd_args.trim().to_string(),
});
return true;
}
"/checkpoint" => {
self.send_command(UiCommand::Checkpoint {
args: cmd_args.trim().to_string(),
});
return true;
}
"/rewind" => {
self.begin_busy("rewinding ...");
self.send_command(UiCommand::Rewind {
name: cmd_args.trim().to_string(),
});
return true;
}
"/btw" => {
self.queue_btw(cmd_args.trim());
return true;
}
"/tan" => {
let work = cmd_args.trim();
if work.is_empty() {
self.push_entry(EntryRole::Error, String::from("Usage: /tan <work>"));
return true;
}
self.push_entry(EntryRole::System, format!("(/tan started) {work}"));
self.send_command(UiCommand::Tan(work.to_string()));
return true;
}
"/login" => {
self.begin_busy("starting login ...");
self.send_command(UiCommand::Login {
args: cmd_args.trim().to_string(),
});
return true;
}
"/logout" => {
self.send_command(UiCommand::Logout {
args: cmd_args.trim().to_string(),
});
return true;
}
"/reload" | "/reload-plugins" => {
self.begin_busy("reloading resources ...");
self.send_command(UiCommand::Reload);
return true;
}
"/open" => {
match last_url(&self.transcript) {
Some(url) => match open_in_browser(&url) {
Ok(()) => self.push_entry(EntryRole::System, format!("Opening {url}")),
Err(err) => {
self.push_entry(EntryRole::Error, format!("open {url}: {err}"));
}
},
None => self.push_entry(
EntryRole::System,
String::from("No link in the conversation yet."),
),
}
return true;
}
"/fork" => {
let args = cmd_args.trim();
if !(args.eq_ignore_ascii_case("list") || args.eq_ignore_ascii_case("ls")) {
self.begin_busy("forking session ...");
}
self.send_command(UiCommand::Fork {
args: args.to_string(),
});
return true;
}
"/share" => {
if !cmd_args.trim().is_empty() {
self.push_entry(
EntryRole::Error,
String::from(
"Usage: /share (uploads a secret, unlisted gist; anyone with its URL can view it; public sharing is disabled)",
),
);
return true;
}
self.push_entry(
EntryRole::System,
String::from(
"Sharing session... (secret gist, not private; transcript may still contain sensitive local context; Ctrl-C to cancel)",
),
);
self.begin_busy(String::from("sharing session ..."));
self.send_command(UiCommand::Share);
return true;
}
"/tree" => {
self.send_command(UiCommand::TreeSummary);
return true;
}
"/branch" => {
self.request_message_picker(false);
return true;
}
"/dump" => {
self.send_command(UiCommand::Dump);
return true;
}
"/delete" => {
if cmd_args.trim().eq_ignore_ascii_case("yes") {
self.begin_busy("deleting session ...");
self.send_command(UiCommand::DeleteSession);
} else {
self.push_entry(
EntryRole::System,
String::from(
"This deletes this session's file (to the trash when one is \
available) and starts a new session. Type /delete yes to confirm.",
),
);
}
return true;
}
"/queue" => {
let message = cmd_args.trim();
if message.is_empty() {
self.push_entry(EntryRole::Error, String::from("Usage: /queue <message>"));
} else {
self.send_command(UiCommand::Prompt(message.to_string()));
}
return true;
}
"/pin" => {
let Some(id) = self.displayed_session_id.clone() else {
self.push_entry(EntryRole::Error, String::from("No active session to pin."));
return true;
};
match session_pins::toggle_pin(&self.pins_dir, &id) {
Ok(true) => self.push_entry(
EntryRole::System,
String::from("Pinned: /resume lists this session first."),
),
Ok(false) => self.push_entry(EntryRole::System, String::from("Unpinned.")),
Err(err) => self.push_entry(EntryRole::Error, format!("pin: {err}")),
}
return true;
}
"/fast" => {
match ToggleRequest::parse(cmd_args) {
Some(request) => self.send_command(UiCommand::Fast(request)),
None => self.push_entry(
EntryRole::Error,
String::from("Usage: /fast [on|off|status]"),
),
}
return true;
}
"/skillful" => {
match ToggleRequest::parse(cmd_args) {
Some(request) => self.send_command(UiCommand::Skillful(request)),
None => self.push_entry(
EntryRole::Error,
String::from("Usage: /skillful [on|off|status]"),
),
}
return true;
}
"/thinking" | "/think" | "/t" => {
let value = cmd_args.trim();
if value.is_empty() {
self.send_command(UiCommand::SetThinking(None));
return true;
}
match value.parse::<crate::model::ThinkingLevel>() {
Ok(level) => self.send_command(UiCommand::SetThinking(Some(level))),
Err(err) => self.push_entry(EntryRole::Error, err),
}
return true;
}
"/name" | "/rename" => {
let name = cmd_args.trim();
if name.is_empty() {
self.push_entry(EntryRole::Error, String::from("Usage: /rename <name>"));
} else {
self.send_command(UiCommand::SetName(name.to_string()));
}
return true;
}
_ => {}
}
if !clean.starts_with("/skill:") {
let body = clean.trim_start_matches('/');
let (name, args) = body.split_once(char::is_whitespace).unwrap_or((body, ""));
if name.is_empty() {
self.push_entry(EntryRole::Error, String::from("Unknown command: /"));
} else {
self.begin_busy(format!("running /{name} ..."));
self.send_command(UiCommand::ExtensionCommand {
name: name.to_string(),
args: args.trim().to_string(),
});
}
return true;
}
false
}
fn handle_picker_key(&mut self, key: &ftui::KeyEvent) {
if self.picker.is_none() {
return;
}
let Some(action) = self.picker_action(key) else {
if let Some(picker) = self.picker.as_mut() {
match key.code {
KeyCode::Backspace if key.modifiers.is_empty() => picker.pop_query_char(),
KeyCode::Char(ch) if (key.modifiers - Modifiers::SHIFT).is_empty() => {
picker.push_query_char(ch);
}
_ => {}
}
}
return;
};
let page = self.body_height().saturating_sub(1).max(1);
match action {
AppAction::SelectUp => {
if let Some(picker) = self.picker.as_mut() {
picker.selected = picker.selected.saturating_sub(1);
}
}
AppAction::SelectDown => {
if let Some(picker) = self.picker.as_mut() {
picker.selected =
(picker.selected + 1).min(picker.shown.len().saturating_sub(1));
}
}
AppAction::SelectPageUp => {
if let Some(picker) = self.picker.as_mut() {
picker.selected = picker.selected.saturating_sub(page);
}
}
AppAction::SelectPageDown => {
if let Some(picker) = self.picker.as_mut() {
picker.selected =
(picker.selected + page).min(picker.shown.len().saturating_sub(1));
}
}
AppAction::SelectCancel => {
let setting_value = self
.picker
.as_ref()
.is_some_and(|picker| matches!(picker.kind, PickerKind::SettingValue(_)));
self.picker = None;
if setting_value {
self.open_settings_picker();
}
}
AppAction::SelectConfirm => {
if self.picker.as_ref().is_some_and(|p| p.shown.is_empty()) {
return;
}
let Some(picker) = self.picker.take() else {
return;
};
let kind = picker.kind;
if let Some(choice) = picker.take_choice() {
self.apply_picker_choice(kind, &choice);
}
}
_ => {}
}
}
fn picker_action(&self, key: &ftui::KeyEvent) -> Option<AppAction> {
let binding = KeyBinding::from_ftui_key(key)?;
let matches = self.keybindings.matching_actions(&binding);
[
AppAction::SelectCancel,
AppAction::SelectConfirm,
AppAction::SelectPageUp,
AppAction::SelectPageDown,
AppAction::SelectUp,
AppAction::SelectDown,
]
.into_iter()
.find(|action| matches.contains(action))
.or_else(|| self.picker_default_alias(key))
}
fn picker_default_alias(&self, key: &ftui::KeyEvent) -> Option<AppAction> {
if !key.modifiers.is_empty() || self.picker.as_ref().is_some_and(|p| !p.query.is_empty()) {
return None;
}
let action = match key.code {
KeyCode::Char('j') => AppAction::SelectDown,
KeyCode::Char('k') => AppAction::SelectUp,
_ => return None,
};
(self.keybindings.get_bindings(action) == KeyBindings::new().get_bindings(action))
.then_some(action)
}
fn apply_picker_choice(&mut self, kind: PickerKind, choice: &str) {
match kind {
PickerKind::Settings => {
let Some(key) = SettingsKey::parse(choice) else {
return;
};
let current = self
.settings_snapshot
.as_ref()
.map(|settings| settings.value(key));
let (items, values) = key
.choices()
.iter()
.map(|(value, label)| {
let selected = current.as_deref() == Some(*value);
(
format!("{label}{}", if selected { " (current)" } else { "" }),
(*value).to_string(),
)
})
.unzip();
let mut picker = PickerOverlay::new(
format!("{} (Enter saves, Esc returns)", key.label()),
items,
values,
PickerKind::SettingValue(key),
);
picker.selected = key
.choices()
.iter()
.position(|(value, _)| current.as_deref() == Some(*value))
.unwrap_or(0);
self.picker = Some(picker);
}
PickerKind::SettingValue(key) => {
self.settings_pending = true;
self.begin_busy("saving settings ...");
self.send_settings_command(UiCommand::SetSetting {
key,
value: choice.to_string(),
});
}
PickerKind::Theme => {
let theme = if choice == "light" {
crate::theme::Theme::light()
} else {
crate::theme::Theme::dark()
};
self.palette = FtuiPalette::from_theme(&theme);
self.render_cache.borrow_mut().clear();
self.push_entry(EntryRole::System, format!("theme set to {choice}"));
self.scroll_from_tail = 0;
}
PickerKind::Model => {
if let Some((provider, model)) = choice.split_once('/') {
self.push_entry(
EntryRole::System,
format!("switching model to {choice} ..."),
);
self.scroll_from_tail = 0;
self.begin_busy(format!("switching model to {choice} ..."));
self.send_command(UiCommand::SetModel {
provider: provider.to_string(),
model: model.to_string(),
});
} else {
self.push_entry(EntryRole::Error, format!("malformed model entry: {choice}"));
}
}
PickerKind::Session => {
self.push_entry(EntryRole::System, String::from("resuming session ..."));
self.scroll_from_tail = 0;
self.begin_busy("loading session ...");
self.send_command(UiCommand::ResumeSession {
path: choice.to_string(),
});
}
PickerKind::Rewind => {
self.scroll_from_tail = 0;
self.begin_busy("rewinding ...");
self.send_command(UiCommand::RewindTo {
entry_id: choice.to_string(),
});
}
PickerKind::ForkFrom => {
self.scroll_from_tail = 0;
self.begin_busy("forking session ...");
self.send_command(UiCommand::Fork {
args: choice.to_string(),
});
}
PickerKind::Copy => {
let outcome = crate::interactive::copy_text_to_clipboard(choice);
self.push_entry(EntryRole::System, outcome);
self.scroll_from_tail = 0;
}
}
}
fn request_settings(&mut self) {
if self.state != AgentUiState::Ready
|| self.busy.is_some()
|| self.active_ask.is_some()
|| self.active_ext.is_some()
|| self.login_pending.is_some()
{
self.push_entry(
EntryRole::Error,
String::from("Settings are available when the current operation finishes."),
);
return;
}
self.autocomplete.close();
self.settings_pending = true;
self.begin_busy("loading settings ...");
self.send_settings_command(UiCommand::Settings);
}
fn send_settings_command(&mut self, command: UiCommand) {
if self
.submit_tx
.as_ref()
.is_none_or(|tx| tx.send(command).is_err())
{
self.settings_pending = false;
self.busy = None;
self.push_entry(
EntryRole::Error,
String::from("Settings require an active agent session."),
);
}
}
fn open_settings_picker(&mut self) {
let Some(snapshot) = &self.settings_snapshot else {
return;
};
let (items, values) = SettingsKey::ALL
.into_iter()
.map(|key| {
(
format!("{}: {}", key.label(), snapshot.value(key)),
key.id().to_string(),
)
})
.unzip();
self.picker = Some(PickerOverlay::new(
format!(
"Settings · {}",
sanitize(&snapshot.path.display().to_string())
),
items,
values,
PickerKind::Settings,
));
}
fn request_message_picker(&mut self, fork: bool) {
if self.state == AgentUiState::Working {
self.push_entry(
EntryRole::Error,
String::from("Wait for the turn to finish (or Esc to stop it) before rewinding"),
);
return;
}
self.send_command(UiCommand::MessagePicker { fork });
}
fn note_idle_escape(&mut self) {
if self.double_escape_action == DoubleEscapeAction::None {
return;
}
let now = Instant::now();
if self
.last_idle_escape
.is_some_and(|last| now.duration_since(last) < Duration::from_millis(500))
{
self.last_idle_escape = None;
match self.double_escape_action {
DoubleEscapeAction::Rewind => self.request_message_picker(false),
DoubleEscapeAction::Fork => self.request_message_picker(true),
DoubleEscapeAction::Tree => self.send_command(UiCommand::TreeSummary),
DoubleEscapeAction::None => {}
}
} else {
self.last_idle_escape = Some(now);
}
}
fn send_command(&self, command: UiCommand) {
if let Some(tx) = &self.submit_tx {
let _ = tx.send(command);
}
}
fn drain_deferred_notes(&mut self) {
for text in std::mem::take(&mut self.deferred_notes) {
self.push_entry(EntryRole::System, text);
}
}
fn begin_busy(&mut self, label: impl Into<String>) {
self.busy = Some(BusyOp {
label: label.into(),
tick_pending: true,
});
}
fn busy_label(&self) -> Option<&str> {
self.busy.as_ref().map(|op| op.label.as_str())
}
fn apply_extension_ui_effect(&mut self, request: &ExtensionUiRequest) -> bool {
let text_field = |keys: &[&str]| -> Option<String> {
keys.iter()
.find_map(|key| request.payload.get(*key).and_then(|v| v.as_str()))
.map(|value| sanitize(value).into_owned())
};
match request.method.as_str() {
"setStatus" | "set_status" => {
let status = text_field(&["statusText", "status_text", "text"]).unwrap_or_default();
self.ext_status = (!status.is_empty()).then_some(status);
true
}
"setTitle" | "set_title" => {
if let Some(title) = text_field(&["title", "text"]) {
Self::write_terminal_title(&title);
}
true
}
"set_editor_text" => {
if let Some(text) = text_field(&["text"]) {
self.input.set_text(&text);
self.maybe_trigger_autocomplete();
}
true
}
_ => false,
}
}
fn answer_extension_ui_query(&mut self, request: &ExtensionUiRequest) -> bool {
let answer = |value: serde_json::Value| ExtensionUiResponse {
id: request.id.clone(),
value: Some(value),
cancelled: false,
};
let requested_name = || {
request
.payload
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_ascii_lowercase()
};
match request.method.as_str() {
"getEditorText" | "get_editor_text" => {
self.send_ext_reply(answer(serde_json::Value::String(self.input.text())));
true
}
"getAllThemes" | "get_all_themes" => {
let themes = ["dark", "light"]
.into_iter()
.map(
|name| serde_json::json!({ "name": name, "path": serde_json::Value::Null }),
)
.collect();
self.send_ext_reply(answer(serde_json::Value::Array(themes)));
true
}
"getTheme" | "get_theme" => {
let value = match requested_name().as_str() {
"dark" => serde_json::to_value(crate::theme::Theme::dark()).ok(),
"light" => serde_json::to_value(crate::theme::Theme::light()).ok(),
_ => None,
};
self.send_ext_reply(answer(value.unwrap_or(serde_json::Value::Null)));
true
}
"setTheme" | "set_theme" => {
let name = requested_name();
let applied = matches!(name.as_str(), "dark" | "light");
if applied {
self.apply_picker_choice(PickerKind::Theme, &name);
}
self.send_ext_reply(answer(serde_json::Value::Bool(applied)));
true
}
_ => false,
}
}
fn write_terminal_title(title: &str) {
use std::io::Write as _;
let sequence = crate::delight::format_terminal_title(title);
let mut out = std::io::stdout().lock();
let _ = out.write_all(sequence.as_bytes());
let _ = out.flush();
}
fn take_busy_tick(&mut self) -> Cmd<PiFtuiMsg> {
if let Some(op) = &mut self.busy
&& std::mem::take(&mut op.tick_pending)
{
Cmd::tick(SPINNER_INTERVAL)
} else {
Cmd::none()
}
}
fn has_pending_cards(&self) -> bool {
self.transcript
.iter()
.rev()
.any(|entry| entry.card == Some(CardState::Pending))
}
#[allow(clippy::too_many_lines)]
fn handle_term(&mut self, event: &Event) -> Cmd<PiFtuiMsg> {
match event {
Event::Tick => {
if self.suspending {
return Cmd::none();
}
if self.state == AgentUiState::Working
|| self.busy.is_some()
|| self.has_pending_cards()
{
self.spinner.tick();
return Cmd::tick(SPINNER_INTERVAL);
}
return Cmd::none();
}
Event::Key(key) => {
if !key_event_is_input(key) {
return Cmd::none();
}
let ctrl_c =
key.code == KeyCode::Char('c') && key.modifiers.contains(Modifiers::CTRL);
let double_tap = self
.last_ctrl_c
.is_some_and(|at| at.elapsed() < CTRL_C_EXIT_WINDOW);
if ctrl_c && !double_tap {
self.last_ctrl_c = Some(std::time::Instant::now());
if self.picker.is_some() {
self.picker = None;
} else {
self.input.set_text("");
self.autocomplete.close();
self.history_cursor = None;
self.history_draft.clear();
}
if self.error_banner.is_none() {
self.error_banner = Some(String::from("Press ctrl+c again to exit"));
}
return Cmd::none();
}
if ctrl_c {
if let Some(slot) = &self.turn_abort
&& let Some(handle) = slot
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
{
handle.abort();
}
if let Some(control) = self.live_turn_control() {
control.abort();
}
return Cmd::quit();
}
if self.settings_pending {
return Cmd::none();
}
if self.picker.is_some() {
self.handle_picker_key(key);
return self.take_busy_tick();
}
let actions = KeyBinding::from_ftui_key(key)
.map(|binding| self.keybindings.matching_actions(&binding))
.unwrap_or_default();
if self.completion_visible()
&& self.handle_completion_key(key, actions.contains(&AppAction::Submit))
{
return Cmd::none();
}
let pick = |wanted: AppAction| actions.contains(&wanted).then_some(wanted);
let action = pick(AppAction::Suspend)
.or_else(|| pick(AppAction::PageUp))
.or_else(|| pick(AppAction::PageDown))
.or_else(|| pick(AppAction::Submit))
.or_else(|| {
(self.state == AgentUiState::Working)
.then(|| pick(AppAction::FollowUp))
.flatten()
})
.or_else(|| pick(AppAction::NewLine))
.or_else(|| pick(AppAction::Interrupt))
.or_else(|| pick(AppAction::CursorLineEnd))
.or_else(|| {
if self.input.is_empty() {
pick(AppAction::Exit)
} else {
None
}
})
.or_else(|| pick(AppAction::SelectModel))
.or_else(|| pick(AppAction::OpenSettings))
.or_else(|| pick(AppAction::Help))
.or_else(|| pick(AppAction::CycleThinkingLevel))
.or_else(|| pick(AppAction::CycleModelForward))
.or_else(|| pick(AppAction::CycleModelBackward))
.or_else(|| pick(AppAction::Dequeue))
.or_else(|| pick(AppAction::CursorLineStart))
.or_else(|| pick(AppAction::CursorWordLeft))
.or_else(|| pick(AppAction::CursorWordRight))
.or_else(|| pick(AppAction::CursorLeft))
.or_else(|| pick(AppAction::CursorRight))
.or_else(|| pick(AppAction::JumpBackward))
.or_else(|| pick(AppAction::JumpForward))
.or_else(|| pick(AppAction::DeleteWordBackward))
.or_else(|| pick(AppAction::DeleteWordForward))
.or_else(|| pick(AppAction::DeleteToLineStart))
.or_else(|| pick(AppAction::DeleteToLineEnd))
.or_else(|| pick(AppAction::DeleteCharBackward))
.or_else(|| pick(AppAction::DeleteCharForward))
.or_else(|| pick(AppAction::Undo))
.or_else(|| pick(AppAction::ExpandTools))
.or_else(|| pick(AppAction::ToggleThinking))
.or_else(|| pick(AppAction::ExternalEditor))
.or_else(|| pick(AppAction::PasteImage))
.or_else(|| pick(AppAction::CursorUp))
.or_else(|| pick(AppAction::CursorDown));
let page = self.body_height().saturating_sub(1).max(1);
match action {
Some(AppAction::Suspend) => {
#[cfg(unix)]
{
self.suspending = true;
#[cfg(test)]
let task = self.suspend_task_override.take().unwrap_or_else(|| {
Box::new(suspend_task(self.alt_screen, self.mouse))
});
#[cfg(not(test))]
let task = suspend_task(self.alt_screen, self.mouse);
return Cmd::task(task);
}
#[cfg(not(unix))]
{
return Cmd::none();
}
}
Some(AppAction::PageUp) => return self.consume_scroll(|m| m.scroll_up(page)),
Some(AppAction::PageDown) => {
return self.consume_scroll(|m| m.scroll_down(page));
}
Some(AppAction::Exit) if self.input.is_empty() => return Cmd::quit(),
Some(AppAction::Interrupt) if self.active_ask.is_some() => {
if let Some(ask) = self.active_ask.take() {
self.push_entry(EntryRole::Ask, String::from(" (dismissed)"));
self.scroll_from_tail = 0;
self.send_ask_reply(ask.request.id, Vec::new(), true);
self.input.set_text("");
self.maybe_activate_queued_ext();
}
return Cmd::none();
}
Some(AppAction::Interrupt) if self.active_ext.is_some() => {
self.cancel_active_ext();
return Cmd::none();
}
Some(AppAction::Interrupt) if self.state == AgentUiState::Working => {
if let Some(control) = self.live_turn_control()
&& control.abort()
{
self.push_entry(EntryRole::System, String::from("Aborting..."));
}
return Cmd::none();
}
Some(AppAction::Interrupt) if self.input.is_empty() && self.busy.is_none() => {
self.note_idle_escape();
return Cmd::none();
}
Some(AppAction::Submit) if self.input_active() => {
if self.active_ask.is_some() {
self.submit_ask_answer();
} else if self.active_ext.is_some() {
self.submit_ext_answer();
} else {
self.submit_input();
if self.pending_quit {
return Cmd::quit();
}
if let Some(task) = self.pending_task.take() {
return Cmd::task(task);
}
}
return self.take_busy_tick();
}
Some(AppAction::FollowUp)
if self.input_active()
&& self.active_ask.is_none()
&& self.active_ext.is_none() =>
{
self.submit_mid_turn(true);
return Cmd::none();
}
Some(AppAction::NewLine) if self.input_active() => {
self.input.insert_newline();
self.maybe_trigger_autocomplete();
return Cmd::none();
}
Some(AppAction::CursorLineEnd) if self.input.is_empty() => {
self.scroll_from_tail = 0;
return Cmd::none();
}
Some(AppAction::SelectModel) => {
self.route_model_command("");
return Cmd::none();
}
Some(AppAction::OpenSettings) => {
self.request_settings();
return self.take_busy_tick();
}
Some(AppAction::Help) => {
self.route_slash_command_tail("/help");
return Cmd::none();
}
Some(AppAction::CycleThinkingLevel) => {
self.send_command(UiCommand::CycleThinking);
return Cmd::none();
}
Some(AppAction::CycleModelForward) => {
self.send_command(UiCommand::CycleModel { forward: true });
return Cmd::none();
}
Some(AppAction::CycleModelBackward) => {
self.send_command(UiCommand::CycleModel { forward: false });
return Cmd::none();
}
Some(AppAction::Dequeue) => {
self.restore_queued_input();
return Cmd::none();
}
Some(AppAction::ExternalEditor)
if self.input_active()
&& self.active_ask.is_none()
&& self.active_ext.is_none() =>
{
self.suspending = true;
let draft = self.input.text();
#[cfg(test)]
let task = self.suspend_task_override.take().unwrap_or_else(|| {
Box::new(external_editor_task(draft, self.alt_screen, self.mouse))
});
#[cfg(not(test))]
let task = external_editor_task(draft, self.alt_screen, self.mouse);
return self.update(task());
}
Some(AppAction::PasteImage)
if self.input_active()
&& self.active_ask.is_none()
&& self.active_ext.is_none() =>
{
if crate::interactive::running_under_wsl() {
return Cmd::task(|| {
PiFtuiMsg::PastedImage(
crate::interactive::paste_clipboard_image_ref(),
)
});
}
self.insert_pasted_image(crate::interactive::paste_clipboard_image_ref());
return Cmd::none();
}
Some(AppAction::ExpandTools) => {
self.tools_expanded = !self.tools_expanded;
self.render_cache.borrow_mut().clear();
return Cmd::none();
}
Some(AppAction::ToggleThinking) => {
self.show_thinking = !self.show_thinking;
self.render_cache.borrow_mut().clear();
return Cmd::none();
}
Some(editing) if self.input_active() && is_editor_action(editing) => {
apply_editor_action(&mut self.input, editing);
self.maybe_trigger_autocomplete();
return Cmd::none();
}
Some(AppAction::CursorUp) if self.history_navigable() => {
self.history_back();
return Cmd::none();
}
Some(AppAction::CursorDown)
if self.history_navigable() && self.history_cursor.is_some() =>
{
self.history_forward();
return Cmd::none();
}
_ => {}
}
if self.input_active() && self.input.handle_event(event) {
self.maybe_trigger_autocomplete();
}
}
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_up(3),
MouseEventKind::ScrollDown => self.scroll_down(3),
_ => {}
},
Event::Resize { width, height } => {
if *width != self.term.0 {
self.render_cache.borrow_mut().clear();
}
self.term = (*width, *height);
self.suspending = false;
self.scroll_from_tail = self.scroll_from_tail.min(self.max_scroll_from_tail());
}
Event::Paste(paste) if self.picker.is_some() => {
if let Some(picker) = self.picker.as_mut() {
picker.push_query_str(&paste.text);
}
}
Event::Paste(_) if self.settings_pending => {}
_ => {
if self.input_active() && self.input.handle_event(event) {
self.maybe_trigger_autocomplete();
}
}
}
Cmd::none()
}
fn input_active(&self) -> bool {
self.state == AgentUiState::Ready
|| self.active_ask.is_some()
|| self.active_ext.is_some()
|| (self.state == AgentUiState::Working && self.turn_control.is_some())
}
#[allow(clippy::useless_let_if_seq)]
fn dismiss_pending_interactions(&mut self) -> bool {
let mut dismissed = if let Some(ask) = self.active_ask.take() {
self.send_ask_reply(ask.request.id, Vec::new(), true);
true
} else {
false
};
if let Some(request) = self.active_ext.take() {
self.send_ext_reply(ExtensionUiResponse {
id: request.id,
value: None,
cancelled: true,
});
dismissed = true;
}
let queued = std::mem::take(&mut self.ext_queue);
if !queued.is_empty() {
dismissed = true;
}
for request in queued {
self.send_ext_reply(ExtensionUiResponse {
id: request.id,
value: None,
cancelled: true,
});
}
if dismissed {
self.input.set_text("");
}
self.restore_card_draft_after_cards_settle();
dismissed
}
fn capture_preexisting_card_draft(&mut self) {
let draft = self.input.text();
if self.card_draft_snapshot.is_none() && !draft.is_empty() {
self.card_draft_snapshot = Some(draft);
self.input.set_text("");
}
}
fn restore_card_draft_after_cards_settle(&mut self) {
if self.active_ask.is_some() || self.active_ext.is_some() || !self.ext_queue.is_empty() {
return;
}
if self.input.text().is_empty()
&& let Some(draft) = self.card_draft_snapshot.take()
{
self.input.set_text(&draft);
}
}
fn apply_conversation_reset(
&mut self,
messages: Vec<crate::interactive::ConversationMessage>,
status: Option<String>,
) {
self.transcript.clear();
self.render_cache.borrow_mut().clear();
self.streaming.clear();
if self.input_history.is_empty() {
for message in &messages {
if message.role == crate::interactive::MessageRole::User {
let text = sanitize(&message.content).into_owned();
self.record_history(&text);
}
}
}
for message in messages {
let role = match message.role {
crate::interactive::MessageRole::User => EntryRole::User,
crate::interactive::MessageRole::Assistant => EntryRole::Assistant,
crate::interactive::MessageRole::Tool | crate::interactive::MessageRole::System => {
EntryRole::System
}
};
if let Some(thinking) = message.thinking.as_deref().map(str::trim)
&& !thinking.is_empty()
{
self.push_entry(EntryRole::Thinking, sanitize(thinking).into_owned());
}
let text = sanitize(&message.content).into_owned();
self.push_entry(role, text);
}
if let Some(status) = status {
let text = sanitize(&status).into_owned();
self.push_entry(EntryRole::System, text);
}
self.scroll_from_tail = 0;
}
fn activate_ext_request(&mut self, request: ExtensionUiRequest) {
self.autocomplete.close();
self.capture_preexisting_card_draft();
let card = format_extension_ui_prompt(&request);
let text = sanitize(card.trim_end()).into_owned();
self.push_entry(EntryRole::Ask, text);
self.scroll_from_tail = 0;
self.active_ext = Some(request);
}
fn send_ext_reply(&self, response: ExtensionUiResponse) {
if let Some(tx) = &self.ext_reply_tx {
let _ = tx.send(response);
}
}
fn submit_ext_answer(&mut self) {
let Some(request) = self.active_ext.take() else {
return;
};
let raw = self.input.text();
self.input.set_text("");
match parse_extension_ui_response(&request, &raw) {
Err(err) => {
let text = format!(" ! {}", sanitize(&err));
self.push_entry(EntryRole::Ask, text);
self.scroll_from_tail = 0;
self.active_ext = Some(request);
}
Ok(response) => {
let echo = if response.cancelled {
String::from(" (cancelled)")
} else {
format!(" → {}", sanitize(raw.trim()))
};
self.push_entry(EntryRole::Ask, echo);
self.scroll_from_tail = 0;
self.send_ext_reply(response);
self.maybe_activate_queued_ext();
}
}
}
fn maybe_activate_queued_ext(&mut self) {
if self.active_ask.is_some() || self.active_ext.is_some() {
return;
}
if let Some(next) = self.ext_queue.pop_front() {
self.activate_ext_request(next);
} else {
self.restore_card_draft_after_cards_settle();
}
}
fn cancel_active_ext(&mut self) {
if let Some(request) = self.active_ext.take() {
self.push_entry(EntryRole::Ask, String::from(" (cancelled)"));
self.scroll_from_tail = 0;
self.send_ext_reply(ExtensionUiResponse {
id: request.id,
value: None,
cancelled: true,
});
self.input.set_text("");
self.maybe_activate_queued_ext();
}
}
fn consume_scroll(&mut self, scroll: impl FnOnce(&mut Self)) -> Cmd<PiFtuiMsg> {
scroll(self);
Cmd::none()
}
const fn table_width_for(cols: u16) -> u16 {
let usable = cols.saturating_sub(2);
if usable < 20 { 20 } else { usable }
}
fn conversation_text(&self, width: u16) -> Text<'static> {
let theme = ftui_extras::markdown::MarkdownTheme::default();
let md = ftui_extras::markdown::MarkdownRenderer::new(theme.clone())
.table_max_width(Self::table_width_for(width));
let palette = self.palette;
let compact = self.markdown_spacing == crate::config::MarkdownSpacing::Compact;
let wrap_width = usize::from(width);
if self.render_cache_width.get() != width {
self.render_cache.borrow_mut().clear();
self.render_cache_width.set(width);
}
let mut cache = self.render_cache.borrow_mut();
cache.resize_with(self.transcript.len(), || None);
let mut rendered_blocks = 0_usize;
let mut reused_blocks = 0_usize;
let mut lines: Vec<ftui::text::Line<'static>> =
Vec::with_capacity(self.conversation_line_count());
for (idx, entry) in self.transcript.iter().enumerate() {
if entry.card == Some(CardState::Pending) {
cache[idx] = None;
rendered_blocks += 1;
let mut block_lines: Vec<ftui::text::Line<'static>> = Vec::new();
push_card_block(
&mut block_lines,
CardState::Pending,
&entry.text,
entry.detail.as_ref(),
entry.diff_styled,
entry.group_count,
&palette,
self.spinner.current_frame,
self.tools_expanded,
);
wrap_body_block(&mut block_lines, wrap_width, &theme);
lines.extend(block_lines);
continue;
}
if let Some(block) = cache[idx].as_ref()
&& block.revision == entry.revision
{
reused_blocks += 1;
lines.extend(block.lines.iter().cloned());
continue;
}
rendered_blocks += 1;
let mut block_lines: Vec<ftui::text::Line<'static>> = Vec::new();
if let Some(state) = entry.card {
push_card_block(
&mut block_lines,
state,
&entry.text,
entry.detail.as_ref(),
entry.diff_styled,
entry.group_count,
&palette,
self.spinner.current_frame,
self.tools_expanded,
);
} else if entry.role == EntryRole::Thinking && !self.show_thinking {
let summary = collapsed_thinking(&entry.text);
push_role_block(&mut block_lines, entry.role, &summary, &palette, &md);
} else {
push_role_block(&mut block_lines, entry.role, &entry.text, &palette, &md);
if compact && entry.role == EntryRole::Assistant {
block_lines = apply_compact_spacing(&block_lines, &theme);
}
}
wrap_body_block(&mut block_lines, wrap_width, &theme);
lines.extend(block_lines.iter().cloned());
cache[idx] = Some(CachedBlock {
revision: entry.revision,
lines: block_lines,
});
}
self.render_stats.set((rendered_blocks, reused_blocks));
let thinking = self.thinking.trim();
if !thinking.is_empty() {
let text = if self.show_thinking {
thinking.to_string()
} else {
collapsed_thinking(thinking)
};
let mut block = Vec::new();
push_role_block(&mut block, EntryRole::Thinking, &text, &palette, &md);
wrap_body_block(&mut block, wrap_width, &theme);
lines.extend(block);
}
if !self.streaming.is_empty() {
let rendered = md.render_streaming(&self.streaming);
let mut tail = if compact {
apply_compact_spacing(rendered.lines(), &theme)
} else {
rendered.lines().to_vec()
};
wrap_body_block(&mut tail, wrap_width, &theme);
lines.extend(tail);
}
Text::from_lines(lines)
}
}
impl Model for PiFtuiModel {
type Message = PiFtuiMsg;
fn update(&mut self, msg: PiFtuiMsg) -> Cmd<PiFtuiMsg> {
let probe = self.watchdog.start();
let phase = match &msg {
PiFtuiMsg::Term(_) | PiFtuiMsg::Edited { .. } | PiFtuiMsg::PastedImage(_) => {
LoopPhase::Input
}
PiFtuiMsg::Agent(_) | PiFtuiMsg::Resumed => LoopPhase::AgentEvent,
};
let cmd = match msg {
PiFtuiMsg::Term(event) => self.handle_term(&event),
PiFtuiMsg::Edited {
text,
width,
height,
} => {
let cmd = self.handle_term(&Event::Resize { width, height });
match text {
Ok(text) => {
self.input.set_text(text.trim_end_matches(['\n', '\r']));
}
Err(err) => self.error_banner = Some(err),
}
cmd
}
PiFtuiMsg::PastedImage(reference) => {
self.insert_pasted_image(reference);
Cmd::none()
}
PiFtuiMsg::Agent(agent) => self.handle_agent(agent),
PiFtuiMsg::Resumed => {
self.suspending = false;
Cmd::none()
}
};
self.watchdog.finish(phase, probe);
cmd
}
fn view(&self, frame: &mut Frame) {
let probe = self.watchdog.start();
self.render_frame(frame);
self.watchdog.finish(LoopPhase::Render, probe);
}
fn subscriptions(&self) -> Vec<Box<dyn Subscription<PiFtuiMsg>>> {
vec![Box::new(AgentEventSubscription::from_shared(Arc::clone(
&self.agent_rx,
)))]
}
}
impl PiFtuiModel {
#[must_use]
pub fn loop_watchdog_snapshot(&self) -> serde_json::Value {
self.watchdog.snapshot()
}
fn render_completion(&self, area: Rect, frame: &mut Frame) {
use ftui::text::{Line, Span};
let items = &self.autocomplete.items;
let height = usize::from(area.height);
let show_hint = height >= 2;
let visible = height
.saturating_sub(usize::from(show_hint))
.min(items.len());
let offset = self.autocomplete.scroll_offset(visible);
let end = (offset + visible).min(items.len());
let window = &items[offset..end];
let label_width = window
.iter()
.map(|item| item.label.chars().count())
.max()
.unwrap_or(0)
.min(32);
let selected_style = ftui::Style::new().bold().fg(self.palette.accent);
let plain_style = ftui::Style::new();
let muted_style = ftui::Style::new().dim().fg(self.palette.muted);
let mut lines = Vec::with_capacity(window.len() + 1);
for (row, item) in window.iter().enumerate() {
let selected = self.autocomplete.selected == Some(offset + row);
let (marker, style) = if selected {
("▸ ", selected_style)
} else {
(" ", plain_style)
};
let mut spans = vec![
Span::styled(marker, style),
Span::styled(format!("{:<label_width$}", item.label), style),
];
if let Some(desc) = item
.description
.as_deref()
.map(str::trim)
.filter(|desc| !desc.is_empty())
{
spans.push(Span::styled(format!(" {}", sanitize(desc)), muted_style));
}
lines.push(Line::from_spans(spans));
}
if show_hint {
let hint = if items.len() > visible {
format!("{COMPLETION_HINT} · {end}/{} shown", items.len())
} else {
String::from(COMPLETION_HINT)
};
lines.push(Line::styled(hint, muted_style));
}
Paragraph::new(Text::from_lines(lines)).render(area, frame);
}
fn render_picker(&self, picker: &PickerOverlay, regions: &Regions, frame: &mut Frame) {
let title_style = ftui::Style::new().bold().fg(self.palette.accent);
let visible = usize::from(regions.body.height).saturating_sub(1);
let shown = picker.shown.len();
let window = picker_window(picker.selected, shown, visible);
let status = if !picker.query.is_empty() {
format!(
" · filter: {} ({shown}/{})",
sanitize(&picker.query),
picker.items.len()
)
} else if shown > visible {
format!(" ({}/{shown})", picker.selected.saturating_add(1))
} else {
String::new()
};
let title = if status.is_empty() {
ftui::text::Line::styled(picker.title.as_str(), title_style)
} else {
ftui::text::Line::from_spans([
ftui::text::Span::styled(picker.title.as_str(), title_style),
ftui::text::Span::styled(status.as_str(), title_style.dim()),
])
};
let mut lines = vec![title];
if shown == 0 {
lines.push(ftui::text::Line::styled(
" no matches (Backspace edits the filter)",
ftui::Style::new().dim().fg(self.palette.muted),
));
}
for (pos, &index) in picker
.shown
.iter()
.enumerate()
.skip(window.start)
.take(window.len())
{
let item = &picker.items[index];
let (marker, style) = if pos == picker.selected {
("▸ ", ftui::Style::new().bold().fg(self.palette.accent))
} else {
(" ", ftui::Style::new())
};
lines.push(ftui::text::Line::from_spans([
ftui::text::Span::styled(marker, style),
ftui::text::Span::styled(item.as_str(), style),
]));
}
Paragraph::new(Text::from_lines(lines)).render(regions.body, frame);
let footer_style = ftui::Style::new().dim().fg(self.palette.muted);
Paragraph::new(Text::from_lines([ftui::text::Line::styled(
PICKER_HINT,
footer_style,
)]))
.render(regions.footer, frame);
}
#[allow(clippy::too_many_lines)]
fn render_frame(&self, frame: &mut Frame) {
let area = Rect::new(0, 0, frame.width(), frame.height());
let regions = layout_regions(
area,
self.input_rows(),
u16::from(self.error_banner.is_some()),
self.completion_rows(),
);
let header = format!("pi · {}", self.state.label());
let header_style = ftui::Style::new().bold().fg(self.palette.accent);
Paragraph::new(Text::from_lines([ftui::text::Line::styled(
header,
header_style,
)]))
.render(regions.header, frame);
if let Some(picker) = &self.picker {
self.render_picker(picker, ®ions, frame);
return;
}
let body_text = self.conversation_text(regions.body.width);
let total_lines = body_text.lines().len();
let visible = usize::from(regions.body.height).max(1);
self.rendered_total_lines.set(total_lines);
let from_tail = self
.scroll_from_tail
.min(total_lines.saturating_sub(visible));
let offset = total_lines.saturating_sub(visible + from_tail);
let window = Text::from_lines(body_text.lines().iter().skip(offset).take(visible).cloned());
Paragraph::new(window).render(regions.body, frame);
if let Some(banner) = &self.error_banner {
let banner_style = ftui::Style::new().bold().fg(self.palette.error);
Paragraph::new(Text::from_lines([ftui::text::Line::styled(
format!("✗ {banner}"),
banner_style,
)]))
.render(regions.banner, frame);
}
let status_line = if self.state == AgentUiState::Working {
let spin = DOTS[self.spinner.current_frame % DOTS.len()];
let activity = self.current_tool.as_ref().map_or_else(
|| {
if self.streaming.is_empty() && !self.thinking.is_empty() {
String::from("thinking ...")
} else {
String::from("responding ...")
}
},
|tool| format!("running {tool} ..."),
);
format!("{spin} {activity}")
} else if let Some(busy) = self.busy_label() {
let spin = DOTS[self.spinner.current_frame % DOTS.len()];
format!("{spin} {busy}")
} else if let Some(status) = &self.ext_status {
status.clone()
} else {
self.todo_summary
.as_ref()
.map_or_else(String::new, |todo| format!("todo {todo}"))
};
let powerline = self
.status_snapshot
.as_ref()
.map_or_else(String::new, |snapshot| {
let used = if status_line.is_empty() {
0
} else {
display_width(&status_line) + 2
};
render_powerline(
snapshot,
usize::from(regions.status.width).saturating_sub(used),
)
});
if !status_line.is_empty() || !powerline.is_empty() {
let status_style = if self.state == AgentUiState::Working || self.busy.is_some() {
ftui::Style::new().fg(self.palette.warning)
} else {
ftui::Style::new().dim().fg(self.palette.muted)
};
let mut spans = Vec::new();
if !status_line.is_empty() {
spans.push(ftui::text::Span::styled(status_line, status_style));
}
if !powerline.is_empty() {
if !spans.is_empty() {
spans.push(ftui::text::Span::raw(String::from(" ")));
}
spans.push(ftui::text::Span::styled(
powerline,
ftui::Style::new().fg(self.palette.accent),
));
}
Paragraph::new(Text::from_lines([ftui::text::Line::from_spans(spans)]))
.render(regions.status, frame);
}
if regions.completion.height > 0 {
self.render_completion(regions.completion, frame);
}
if self.input_active() {
self.input.render(regions.input, frame);
} else {
Paragraph::new(Text::raw(
"… processing (esc to abort, ctrl+c twice to quit)",
))
.render(regions.input, frame);
}
let footer = if from_tail > 0 {
format!("[{from_tail} lines up] End to follow")
} else if let Some(usage) = &self.usage_line {
usage.clone()
} else {
String::from("pi")
};
let footer_style = ftui::Style::new().dim().fg(self.palette.muted);
Paragraph::new(Text::from_lines([ftui::text::Line::styled(
footer,
footer_style,
)]))
.render(regions.footer, frame);
}
}
fn tool_output_preview(result: &crate::tools::ToolOutput) -> Option<String> {
const MAX_DETAIL_LINES: usize = KEPT_DETAIL_LINES;
const MAX_LINE_CHARS: usize = 300;
let mut text = String::new();
for block in &result.content {
if let crate::model::ContentBlock::Text(t) = block {
if !text.is_empty() {
text.push('\n');
}
text.push_str(&t.text);
}
}
if text.trim().is_empty() {
return None;
}
let total = text.lines().count();
let mut preview = text
.lines()
.take(MAX_DETAIL_LINES)
.map(|line| match line.char_indices().nth(MAX_LINE_CHARS) {
Some((cut, _)) => format!("{}…", &line[..cut]),
None => line.to_string(),
})
.collect::<Vec<_>>()
.join("\n");
if total > MAX_DETAIL_LINES {
let _ = write!(preview, "\n… +{} more lines", total - MAX_DETAIL_LINES);
}
Some(preview)
}
pub fn agent_event_to_pi_msgs(event: &crate::agent::AgentEvent) -> Vec<PiMsg> {
use crate::agent::AgentEvent as E;
use crate::model::AssistantMessageEvent as A;
match event {
E::AgentStart { .. } => vec![PiMsg::AgentStart],
E::AgentEnd {
messages, error, ..
} => {
let last_assistant = messages.iter().rev().find_map(|message| match message {
crate::model::Message::Assistant(assistant) => Some(assistant),
_ => None,
});
let stop_reason =
last_assistant.map_or(crate::model::StopReason::Stop, |a| a.stop_reason);
let error_message = error.as_ref().map(|raw| {
if stop_reason == crate::model::StopReason::Error {
crate::error::ProviderErrorSummary::from_error_text(
last_assistant.map(|a| a.provider.as_str()),
raw,
)
.turn_end_card(raw, None)
} else {
raw.clone()
}
});
vec![PiMsg::AgentDone {
usage: last_assistant.map(|a| a.usage.clone()),
stop_reason,
error_message,
}]
}
E::MessageUpdate {
assistant_message_event,
..
} => match assistant_message_event {
A::TextDelta { delta, .. } => vec![PiMsg::TextDelta(delta.clone())],
A::ThinkingDelta { delta, .. } => vec![PiMsg::ThinkingDelta(delta.clone())],
_ => Vec::new(),
},
E::ToolExecutionStart {
tool_call_id,
tool_name,
args,
..
} => {
let mut msgs = vec![PiMsg::ToolStart {
name: tool_name.clone(),
tool_id: tool_call_id.clone(),
}];
if let Some(summary) = crate::interactive::tool_invocation_summary(tool_name, args) {
msgs.push(PiMsg::ToolInvocation {
tool_id: tool_call_id.clone(),
summary,
});
}
msgs
}
E::ToolExecutionEnd {
tool_call_id,
tool_name,
is_error,
result,
..
} => vec![PiMsg::ToolEnd {
name: tool_name.clone(),
tool_id: tool_call_id.clone(),
is_error: *is_error,
output: tool_output_preview(result),
}],
E::AutoRetryStart {
attempt,
max_attempts,
error_message,
..
} => vec![PiMsg::SystemNote(format!(
"retry {attempt}/{max_attempts}: {error_message}"
))],
E::AutoCompactionStart { reason } => {
vec![PiMsg::SystemNote(format!("compacting context: {reason}"))]
}
E::AutoCompactionEnd {
aborted,
error_message,
..
} => {
let note = if *aborted {
String::from("compaction aborted")
} else if let Some(err) = error_message {
format!("compaction failed: {err}")
} else {
String::from("compaction complete")
};
vec![PiMsg::SystemNote(note)]
}
E::ExtensionError { event, error, .. } => {
vec![PiMsg::System(format!("extension error ({event}): {error}"))]
}
_ => Vec::new(),
}
}
const SUBMIT_POLL: Duration = Duration::from_millis(50);
const INLINE_MIN_HEIGHT: u16 = 10;
const INLINE_MAX_HEIGHT: u16 = 15;
const EXT_UI_TIMEOUT_MS: u64 = 300_000;
struct FtuiExtensionUiHandler {
agent_tx: Sender<PiMsg>,
command_registry: std::sync::Weak<Mutex<Option<crate::extensions::ExtensionManager>>>,
reply_channel_open: std::sync::atomic::AtomicBool,
pending: Mutex<
std::collections::HashMap<
String,
asupersync::channel::oneshot::Sender<ExtensionUiResponse>,
>,
>,
}
impl FtuiExtensionUiHandler {
fn new(agent_tx: Sender<PiMsg>) -> Self {
Self {
agent_tx,
command_registry: std::sync::Weak::new(),
reply_channel_open: std::sync::atomic::AtomicBool::new(true),
pending: Mutex::new(std::collections::HashMap::new()),
}
}
fn install_command_registry(&self, handle: &crate::sdk::AgentSessionHandle) {
if let Some(registry) = self.command_registry.upgrade() {
*registry
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
handle.extension_manager().cloned();
}
}
const fn cancelled_response(id: String) -> ExtensionUiResponse {
ExtensionUiResponse {
id,
value: None,
cancelled: true,
}
}
fn resolve(&self, response: ExtensionUiResponse) {
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let sender = self
.pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&response.id);
if let Some(sender) = sender {
let _ = sender.send(cx.cx(), response);
}
}
fn drop_pending(&self, id: &str) {
self.pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(id);
}
fn cancel_all_pending(&self) -> usize {
let pending = {
let mut pending = self
.pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
self.reply_channel_open
.store(false, std::sync::atomic::Ordering::Release);
std::mem::take(&mut *pending)
};
let count = pending.len();
let cx = crate::agent_cx::AgentCx::for_current_or_request();
for (id, sender) in pending {
let _ = sender.send(cx.cx(), Self::cancelled_response(id));
}
count
}
}
struct FtuiPendingUiLease<'a> {
handler: &'a FtuiExtensionUiHandler,
id: String,
}
impl Drop for FtuiPendingUiLease<'_> {
fn drop(&mut self) {
self.handler.drop_pending(&self.id);
}
}
#[async_trait::async_trait]
impl crate::sdk::ExtensionUiHandler for FtuiExtensionUiHandler {
#[allow(clippy::significant_drop_tightening)]
async fn request_ui(
&self,
request: ExtensionUiRequest,
) -> crate::error::Result<Option<ExtensionUiResponse>> {
let cx = crate::agent_cx::AgentCx::for_current_or_request();
let id = request.id.clone();
if !self
.reply_channel_open
.load(std::sync::atomic::Ordering::Acquire)
{
return Ok(Some(Self::cancelled_response(id)));
}
if !request.expects_response() {
let _ = self.agent_tx.send(PiMsg::ExtensionUiRequest(request));
return Ok(None);
}
let timeout_ms = request.timeout_ms.unwrap_or(EXT_UI_TIMEOUT_MS);
let (reply_tx, mut reply_rx) = asupersync::channel::oneshot::channel();
{
let mut pending = self
.pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !self
.reply_channel_open
.load(std::sync::atomic::Ordering::Acquire)
{
return Ok(Some(Self::cancelled_response(id)));
}
pending.insert(id.clone(), reply_tx);
if self
.agent_tx
.send(PiMsg::ExtensionUiRequest(request))
.is_err()
{
pending.remove(&id);
return Ok(None);
}
}
let _pending_reply = FtuiPendingUiLease {
handler: self,
id: id.clone(),
};
let waited = asupersync::time::timeout(
asupersync::time::wall_now(),
std::time::Duration::from_millis(timeout_ms),
reply_rx.recv(cx.cx()),
)
.await;
if let Ok(Ok(response)) = waited {
Ok(Some(response))
} else {
self.drop_pending(&id);
Ok(Some(Self::cancelled_response(id)))
}
}
}
enum ExtReplyPoll {
Resolved,
Empty,
Disconnected,
}
fn poll_ext_reply(
handler: &FtuiExtensionUiHandler,
ext_reply_rx: &Receiver<ExtensionUiResponse>,
) -> ExtReplyPoll {
match ext_reply_rx.try_recv() {
Ok(response) => {
handler.resolve(response);
ExtReplyPoll::Resolved
}
Err(std::sync::mpsc::TryRecvError::Empty) => ExtReplyPoll::Empty,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
handler.cancel_all_pending();
ExtReplyPoll::Disconnected
}
}
}
fn spawn_ext_reply_pump(
handler: Arc<FtuiExtensionUiHandler>,
ext_reply_rx: Receiver<ExtensionUiResponse>,
runtime_handle: &asupersync::runtime::RuntimeHandle,
) {
runtime_handle.spawn(async move {
loop {
match poll_ext_reply(&handler, &ext_reply_rx) {
ExtReplyPoll::Resolved => {}
ExtReplyPoll::Empty => {
asupersync::time::sleep(asupersync::time::wall_now(), SUBMIT_POLL).await;
}
ExtReplyPoll::Disconnected => break,
}
}
});
}
fn install_ask_bridges(
handle: &crate::sdk::AgentSessionHandle,
agent_tx: &Sender<PiMsg>,
ask_reply_rx: Receiver<AskUiReply>,
runtime_handle: &asupersync::runtime::RuntimeHandle,
) -> CurrentAsk {
let current_ask: CurrentAsk = Arc::new(Mutex::new(handle.ask_tool()));
if let Some(ask) = handle.ask_tool() {
drop(install_ask_forwarder(&ask, agent_tx, runtime_handle));
}
spawn_ask_reply_pump(Arc::clone(¤t_ask), ask_reply_rx, runtime_handle);
current_ask
}
type CurrentAsk = Arc<Mutex<Option<crate::ask::AskTool>>>;
type ExtensionCommandRegistry = Arc<Mutex<Option<crate::extensions::ExtensionManager>>>;
type TurnAbortSlot = Arc<Mutex<Option<crate::agent::AbortHandle>>>;
const BTW_USAGE: &str = "Usage: /btw <question>";
const CTRL_C_EXIT_WINDOW: std::time::Duration = std::time::Duration::from_millis(500);
const BTW_UNAVAILABLE: &str =
"/btw unavailable: no smol role model configured (set --smol or model_roles.smol)";
async fn run_btw_command(
handle: &crate::sdk::AgentSessionHandle,
client: Option<&Arc<crate::btw::BtwClient>>,
question: String,
owner_session_id: String,
agent_tx: &Sender<PiMsg>,
runtime_handle: &asupersync::runtime::RuntimeHandle,
) {
let Some(client) = client.cloned() else {
let _ = agent_tx.send(PiMsg::AgentError(String::from(BTW_UNAVAILABLE)));
return;
};
let Ok(current_session_id) = handle
.with_session(|session| session.header.id.clone())
.await
else {
let _ = agent_tx.send(PiMsg::AgentError(String::from("/btw: session busy")));
return;
};
if current_session_id != owner_session_id {
return;
}
let prepared = match client.prepare_with_agent(&handle.session().agent, &question) {
Ok(prepared) => prepared,
Err(err) => {
let _ = agent_tx.send(PiMsg::AgentError(format!("/btw refused: {err}")));
return;
}
};
let tx = agent_tx.clone();
runtime_handle.spawn(async move {
let message = match client.ask_prepared(prepared).await {
Ok(answer) => format!("(/btw) {answer}"),
Err(err) => format!("(/btw) failed: {err}"),
};
let _ = tx.send(PiMsg::SessionSystemNote {
owner_session_id,
message,
});
});
}
type TurnControlSlot = Arc<Mutex<Option<crate::session_control::SessionControlHandle>>>;
fn install_ask_forwarder(
ask: &crate::ask::AskTool,
agent_tx: &Sender<PiMsg>,
runtime_handle: &asupersync::runtime::RuntimeHandle,
) -> asupersync::runtime::JoinHandle<()> {
let (ask_ui_tx, mut ask_ui_rx) = asupersync::channel::mpsc::channel::<AskUiRequest>(4);
ask.install_channel_ui(ask_ui_tx);
let ask_forwarder = ask.clone();
let ask_fwd_tx = agent_tx.clone();
runtime_handle.spawn(async move {
let cx = crate::agent_cx::AgentCx::for_request();
while let Ok(request) = ask_ui_rx.recv(&cx).await {
forward_ask_ui_request(&ask_forwarder, &ask_fwd_tx, request);
}
})
}
fn forward_ask_ui_request(
ask: &crate::ask::AskTool,
agent_tx: &Sender<PiMsg>,
request: AskUiRequest,
) -> bool {
let request_id = request.id.clone();
ask.try_forward_channel_ui_request(&request_id, || {
agent_tx.send(PiMsg::AskUiRequest(request)).is_ok()
})
}
enum AskReplyPoll {
Resolved,
Empty,
Disconnected,
}
#[allow(clippy::significant_drop_tightening)]
fn poll_ask_reply(current_ask: &CurrentAsk, ask_reply_rx: &Receiver<AskUiReply>) -> AskReplyPoll {
match ask_reply_rx.try_recv() {
Ok(reply) => {
let guard = current_ask
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(ask) = guard.as_ref() {
let _ = ask.respond_ui(&reply.request_id, reply.response);
}
AskReplyPoll::Resolved
}
Err(std::sync::mpsc::TryRecvError::Empty) => AskReplyPoll::Empty,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
let guard = current_ask
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(ask) = guard.as_ref() {
ask.close_channel_ui();
}
AskReplyPoll::Disconnected
}
}
}
fn spawn_ask_reply_pump(
current_ask: CurrentAsk,
ask_reply_rx: Receiver<AskUiReply>,
runtime_handle: &asupersync::runtime::RuntimeHandle,
) {
runtime_handle.spawn(async move {
loop {
match poll_ask_reply(¤t_ask, &ask_reply_rx) {
AskReplyPoll::Resolved => {}
AskReplyPoll::Empty => {
asupersync::time::sleep(asupersync::time::wall_now(), SUBMIT_POLL).await;
}
AskReplyPoll::Disconnected => break,
}
}
});
}
async fn run_share_command(
handle: &crate::sdk::AgentSessionHandle,
cwd: &std::path::Path,
gh_path: Option<String>,
turn_abort: &TurnAbortSlot,
agent_tx: &Sender<PiMsg>,
) {
use crate::interactive::share::ShareOutcome;
let (abort_handle, abort_signal) = crate::agent::AbortHandle::new();
*turn_abort
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(abort_handle);
let store = handle.session_store();
let outcome = crate::interactive::share::run_share(gh_path, &store, cwd, &abort_signal).await;
*turn_abort
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
let _ = agent_tx.send(match outcome {
ShareOutcome::Created(report) => PiMsg::System(report),
ShareOutcome::Cancelled => PiMsg::System(String::from("Share cancelled")),
ShareOutcome::Failed(reason) => PiMsg::AgentError(reason),
});
}
async fn run_tan_command(
handle: &crate::sdk::AgentSessionHandle,
cwd: &std::path::Path,
role_spec: Option<String>,
work: String,
agent_tx: &Sender<PiMsg>,
runtime_handle: &asupersync::runtime::RuntimeHandle,
) {
if !handle.has_tool("subagent") {
let _ = agent_tx.send(PiMsg::AgentError(String::from(
"/tan unavailable: enable the opt-in subagent tool with --tools ...subagent",
)));
return;
}
let owner_session_id = match handle
.with_session(|session| session.header.id.clone())
.await
{
Ok(id) => id,
Err(err) => {
let _ = agent_tx.send(PiMsg::AgentError(format!("/tan: {err}")));
return;
}
};
let parent_model = handle
.session()
.agent
.shared_tools()
.snapshot()
.model_scope()
.current();
let tool = crate::subagents::SubagentTool::new(cwd)
.with_role_model_spec(role_spec)
.with_parent_model(parent_model);
let tx = agent_tx.clone();
runtime_handle.spawn(async move {
let message = match tool.run_background_tan(&work).await {
Ok(completion) => {
crate::jobs::push_completion_notice(&owner_session_id, completion.follow_up_text())
.map_or_else(
|err| format!("(/tan failed to queue follow-up)\n{err}"),
|()| completion.card_text(),
)
}
Err(err) => format!("(/tan failed)\n{err}"),
};
let _ = tx.send(PiMsg::SessionSystemNote {
owner_session_id,
message,
});
});
}
async fn run_prompt_turn(
handle: &mut crate::sdk::AgentSessionHandle,
prompt: String,
attachments: Vec<crate::model::ContentBlock>,
agent_tx: &Sender<PiMsg>,
turn_control: &TurnControlSlot,
) {
let mut next = Some((prompt, attachments));
while let Some((prompt, attachments)) = next.take() {
let (leftover, stopped) =
run_controlled_turn(handle, prompt, attachments, agent_tx, turn_control).await;
if leftover.is_empty() {
continue;
}
let text = leftover.join("\n\n");
if stopped {
if let Ok(owner_session_id) = handle
.with_session(|session| session.header.id.clone())
.await
{
let _ = agent_tx.send(PiMsg::RestorePendingInput {
owner_session_id,
text,
});
let _ = agent_tx.send(PiMsg::System(format!(
"Restored {} unsent message(s) to the editor.",
leftover.len()
)));
}
} else {
let _ = agent_tx.send(PiMsg::System(format!(
"Running {} message(s) sent as the last turn ended.",
leftover.len()
)));
next = Some((text, Vec::new()));
}
}
}
#[allow(clippy::option_if_let_else)]
async fn run_controlled_turn(
handle: &mut crate::sdk::AgentSessionHandle,
prompt: String,
attachments: Vec<crate::model::ContentBlock>,
agent_tx: &Sender<PiMsg>,
turn_control: &TurnControlSlot,
) -> (Vec<String>, bool) {
let tx = agent_tx.clone();
let content = if attachments.is_empty() {
crate::model::UserContent::Text(prompt)
} else {
let mut blocks = Vec::with_capacity(attachments.len() + 1);
if !prompt.is_empty() {
blocks.push(crate::model::ContentBlock::Text(
crate::model::TextContent::new(prompt),
));
}
blocks.extend(attachments);
crate::model::UserContent::Blocks(blocks)
};
let turn = handle.prompt_controlled_input(content, move |event| {
for msg in agent_event_to_pi_msgs(&event) {
let _ = tx.send(msg);
}
});
let control = turn.control();
*turn_control
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(control.clone());
let result = turn.await;
*turn_control
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
let leftover = control
.take_pending()
.into_iter()
.map(|input| input.text)
.collect::<Vec<_>>();
let stopped = match &result {
Err(_) => true,
Ok(message) => {
matches!(
message.stop_reason,
crate::model::StopReason::Aborted | crate::model::StopReason::Error
) || crate::agent::time_cap_marker(message).is_some()
}
};
report_turn_result(result, agent_tx);
(leftover, stopped)
}
fn report_turn_result(
result: crate::error::Result<crate::model::AssistantMessage>,
agent_tx: &Sender<PiMsg>,
) {
match result {
Err(err @ crate::error::Error::Provider { .. }) => {
let raw = err.to_string();
let headline =
crate::error::ProviderErrorSummary::from_error_text(None, &raw).headline();
let _ = agent_tx.send(PiMsg::AgentError(headline));
}
Err(err) => {
let _ = agent_tx.send(PiMsg::AgentError(err.to_string()));
}
Ok(message) if message.stop_reason == crate::model::StopReason::Error => {
let raw = message.error_message.as_deref().unwrap_or("Request failed");
let headline =
crate::error::ProviderErrorSummary::from_error_text(Some(&message.provider), raw)
.headline();
let _ = agent_tx.send(PiMsg::AgentError(headline));
}
Ok(message) => {
if let Some(marker) = crate::agent::time_cap_marker(&message) {
let _ = agent_tx.send(PiMsg::System(marker.to_string()));
}
}
}
}
fn resume_template_from(options: &crate::sdk::SessionOptions) -> crate::sdk::SessionOptions {
let mut template = options.clone();
template.no_session = false;
template.session_path = None;
template.extension_ui_handler = None;
template.runtime_handle = None;
template
}
fn replacement_options(
template: &crate::sdk::SessionOptions,
ext_handler: &Arc<FtuiExtensionUiHandler>,
runtime_handle: &asupersync::runtime::RuntimeHandle,
) -> crate::sdk::SessionOptions {
let mut options = template.clone();
options.extension_ui_handler =
Some(Arc::clone(ext_handler) as Arc<dyn crate::sdk::ExtensionUiHandler>);
options.runtime_handle = Some(runtime_handle.clone());
options.no_session = false;
options
}
const EXT_COMMAND_TIMEOUT_MS: u64 = 24 * 60 * 60 * 1000;
fn unrouted_command_message(name: &str, extensions_enabled: bool) -> String {
if crate::interactive::SlashCommand::parse(&format!("/{name}")).is_some() {
return format!(
"/{name} is a pi command that this stack does not implement yet; run `pi --classic` for it"
);
}
if extensions_enabled {
format!("Unknown command: /{name} (try /help)")
} else {
format!("Unknown command: /{name} (extensions disabled; try /help)")
}
}
fn prepare_prompt(
prompt: &str,
resources: Option<&crate::resources::ResourceLoader>,
cwd: &std::path::Path,
workspace: Option<&crate::workspace::WorkspaceHandle>,
auto_resize_images: bool,
) -> std::result::Result<(String, Vec<crate::model::ContentBlock>), String> {
prepare_prompt_with_source(prompt, resources, cwd, workspace, auto_resize_images)
.map(|(text, attachments, _)| (text, attachments))
}
fn prepare_prompt_with_source(
prompt: &str,
resources: Option<&crate::resources::ResourceLoader>,
cwd: &std::path::Path,
workspace: Option<&crate::workspace::WorkspaceHandle>,
auto_resize_images: bool,
) -> std::result::Result<(String, Vec<crate::model::ContentBlock>, String), String> {
let expand = |text: &str| resources.map_or_else(|| text.to_string(), |r| r.expand_input(text));
let (without_refs, refs) = crate::interactive::extract_file_references(prompt, |path| {
crate::tools::resolve_read_path(path, cwd)
.exists()
.then(|| path.to_string())
});
if refs.is_empty() {
return Ok((expand(prompt), Vec::new(), prompt.to_string()));
}
let single;
#[allow(clippy::option_if_let_else)]
let workspace = if let Some(workspace) = workspace {
workspace
} else {
single = crate::workspace::WorkspaceHandle::single(cwd);
&single
};
let processed = crate::tools::process_file_arguments(&refs, cwd, auto_resize_images, workspace)
.map_err(|err| err.to_string())?;
let mut text = processed.text;
let keyword_scan_source = without_refs.trim().to_string();
let message = expand(&keyword_scan_source);
if !message.trim().is_empty() {
text.push_str(&message);
}
Ok((text, processed.attachments, keyword_scan_source))
}
#[allow(clippy::too_many_arguments)]
async fn run_typed_prompt(
handle: &mut crate::sdk::AgentSessionHandle,
prompt: String,
resources: Option<&crate::resources::ResourceLoader>,
cwd: &std::path::Path,
auto_resize_images: bool,
agent_tx: &Sender<PiMsg>,
turn_control: &TurnControlSlot,
) {
let registered = handle.extension_manager().and_then(|manager| {
registered_prompt_command(&extension_commands_for_catalog(manager), &prompt)
});
if let Some((name, args)) = registered {
run_extension_command(handle, cwd, &name, &args, true, agent_tx).await;
return;
}
let workspace = handle.workspace();
match prepare_prompt_with_source(
&prompt,
resources,
cwd,
workspace.as_ref(),
auto_resize_images,
) {
Ok((text, attachments, keyword_scan_source)) => {
handle
.session_mut()
.agent
.set_magic_keyword_scan_override(Some(keyword_scan_source));
run_prompt_turn(handle, text, attachments, agent_tx, turn_control).await;
handle
.session_mut()
.agent
.set_magic_keyword_scan_override(None);
}
Err(err) => {
if let Ok(owner_session_id) = handle
.with_session(|session| session.header.id.clone())
.await
{
let _ = agent_tx.send(PiMsg::SetEditorText {
owner_session_id,
text: prompt,
});
}
let _ = agent_tx.send(PiMsg::AgentError(format!("Not sent: {err}")));
}
}
}
fn registered_prompt_command(
commands: &[crate::autocomplete::NamedEntry],
input: &str,
) -> Option<(String, String)> {
let clean = input.trim();
if !clean.starts_with('/') {
return None;
}
let (token, args) = clean.split_once(char::is_whitespace).unwrap_or((clean, ""));
extension_command_name(commands, token).map(|name| (name.to_string(), args.trim().to_string()))
}
fn expand_initial_prompt(
mut initial: crate::app::InitialMessage,
resources: Option<&crate::resources::ResourceLoader>,
) -> crate::app::InitialMessage {
if let Some(resources) = resources
&& let Some(prefix_len) = initial
.text
.strip_suffix(&initial.keyword_scan_source)
.map(str::len)
{
let expanded = resources.expand_input(&initial.keyword_scan_source);
initial.text.truncate(prefix_len);
initial.text.push_str(&expanded);
}
initial
}
fn prompt_display(text: &str, attachments: &[crate::model::ContentBlock]) -> String {
use crate::model::ContentBlock;
let mut parts = Vec::new();
if !text.is_empty() {
parts.push(text.to_string());
}
for block in attachments {
match block {
ContentBlock::Text(text) => parts.push(text.text.clone()),
ContentBlock::Image(image) => parts.push(format!("[Image: {}]", image.mime_type)),
ContentBlock::Media(media) => parts.push(format!(
"[Media: {}, {}, {}]",
media.name.as_deref().unwrap_or("unnamed"),
media.mime_type,
crate::model::format_media_size(media.decoded_size_bytes())
)),
_ => {}
}
}
parts.join("\n")
}
async fn publish_submitted_input(
handle: &crate::sdk::AgentSessionHandle,
agent_tx: &Sender<PiMsg>,
text: String,
history_text: String,
) -> bool {
match handle
.with_session(|session| session.header.id.clone())
.await
{
Ok(owner_session_id) => agent_tx
.send(PiMsg::UserInputSubmitted {
owner_session_id,
text,
history_text,
})
.is_ok(),
Err(err) => {
let _ = agent_tx.send(PiMsg::AgentError(format!("startup input: {err}")));
false
}
}
}
async fn run_initial_prompt(
handle: &mut crate::sdk::AgentSessionHandle,
pending: &mut Option<crate::app::InitialMessage>,
resources: Option<&crate::resources::ResourceLoader>,
agent_tx: &Sender<PiMsg>,
turn_control: &TurnControlSlot,
) {
let Some(initial) = pending.take() else {
return;
};
if !publish_submitted_input(
handle,
agent_tx,
prompt_display(&initial.text, &initial.attachments),
initial.keyword_scan_source.clone(),
)
.await
{
return;
}
let initial = expand_initial_prompt(initial, resources);
let content = crate::app::build_initial_content(&initial);
handle
.session_mut()
.agent
.set_magic_keyword_scan_override(Some(initial.keyword_scan_source));
run_prompt_turn(handle, String::new(), content, agent_tx, turn_control).await;
handle
.session_mut()
.agent
.set_magic_keyword_scan_override(None);
}
fn template_prompt(
resources: Option<&crate::resources::ResourceLoader>,
extension_claims: bool,
name: &str,
args: &str,
) -> Option<String> {
let resources = resources?;
if extension_claims
|| !resources
.prompts()
.iter()
.any(|template| template.name == name)
{
return None;
}
let input = if args.is_empty() {
format!("/{name}")
} else {
format!("/{name} {args}")
};
Some(resources.expand_input(&input))
}
async fn run_extension_command(
handle: &crate::sdk::AgentSessionHandle,
cwd: &std::path::Path,
name: &str,
args: &str,
expected_registration: bool,
agent_tx: &Sender<PiMsg>,
) {
let unavailable = |extensions_enabled| {
if expected_registration {
format!(
"Extension command '/{name}' is no longer registered; submit it again to use the current command."
)
} else {
unrouted_command_message(name, extensions_enabled)
}
};
let manager = handle
.session()
.extensions
.as_ref()
.map(|region| region.manager().clone());
let Some(manager) = manager else {
let _ = agent_tx.send(PiMsg::System(unavailable(false)));
return;
};
let commands = extension_commands_for_catalog(&manager);
let Some(name) = extension_command_name(&commands, name) else {
let _ = agent_tx.send(PiMsg::System(unavailable(true)));
return;
};
let Some(runtime) = manager.runtime() else {
let _ = agent_tx.send(PiMsg::System(format!(
"Extension command '/{name}' is not available (runtime not enabled)"
)));
return;
};
let _ = agent_tx.send(PiMsg::ToolStart {
name: format!("/{name}"),
tool_id: String::from("ftui-ext-command"),
});
let ctx_payload = serde_json::json!({
"cwd": cwd.display().to_string(),
"hasUI": true,
});
let result = runtime
.execute_command(
name.to_string(),
args.to_string(),
Arc::new(ctx_payload),
EXT_COMMAND_TIMEOUT_MS,
)
.await;
let is_error = result.is_err();
let msg = match result {
Ok(value) if value.is_null() => PiMsg::SystemNote(format!("/{name} done")),
Ok(value) => PiMsg::SystemNote(format!("/{name} → {value}")),
Err(err) => PiMsg::AgentError(format!("/{name}: {err}")),
};
let _ = agent_tx.send(PiMsg::ToolEnd {
name: format!("/{name}"),
tool_id: String::from("ftui-ext-command"),
is_error,
output: None,
});
let _ = agent_tx.send(msg);
}
async fn run_mcp_command(
handle: &crate::sdk::AgentSessionHandle,
subcommand: &str,
name: Option<&str>,
agent_tx: &Sender<PiMsg>,
) {
let Some(manager) = handle.mcp_manager() else {
let _ = agent_tx.send(PiMsg::AgentError(String::from(
"MCP discovery is disabled for this session",
)));
return;
};
if subcommand == "list" {
let rows = manager.list();
let mut content = String::from("MCP servers (Model Context Protocol)\n");
if rows.is_empty() {
content.push_str("\n No MCP servers configured.\n");
} else {
let _ = writeln!(content, "\n {} configured:", rows.len());
for row in rows {
let _ = writeln!(
content,
" • {} — {} [{}; trust: {}; {}]",
row.name, row.target, row.provenance, row.trust, row.health
);
}
}
for warning in manager.warnings() {
let _ = writeln!(content, " ⚠ {warning}");
}
let _ = agent_tx.send(PiMsg::System(content));
return;
}
let Some(name) = name else {
let _ = agent_tx.send(PiMsg::AgentError(format!(
"usage: /mcp {subcommand} <name>"
)));
return;
};
let outcome = match subcommand {
"deny" => manager.deny(name).await.map(|()| Vec::new()),
"test" => manager.test(name).await,
"trust" => manager.trust(name).await,
_ => {
let _ = agent_tx.send(PiMsg::AgentError(format!(
"unknown /mcp subcommand {subcommand:?}"
)));
return;
}
};
let changed = crate::mcp::reconcile_tools(&manager, &handle.session().agent.shared_tools());
let message = match outcome {
Ok(_) if subcommand == "deny" => format!("MCP server {name:?} denied and stopped."),
Ok(tools) => {
let verb = if subcommand == "test" {
"tested"
} else {
"trusted"
};
let mut line = format!(
"MCP server {name:?} {verb}: {} tool(s) available.",
tools.len()
);
for tool in tools.iter().take(12) {
let _ = writeln!(line, " • {} — {}", tool.name, tool.description);
}
if tools.len() > 12 {
let _ = writeln!(line, " … and {} more", tools.len() - 12);
}
if changed > 0 {
let _ = writeln!(
line,
"Updated {changed} MCP tool definition(s) in the live session."
);
}
line
}
Err(err) => format!("MCP {name:?}: {err}"),
};
let _ = agent_tx.send(PiMsg::System(message));
}
type DriverLogin = (
crate::interactive::login_flow::PendingLogin,
Option<crate::auth::OAuthCallbackServer>,
);
fn send_login_pending(agent_tx: &Sender<PiMsg>, pending: Option<&DriverLogin>) {
let _ = agent_tx.send(PiMsg::LoginPending {
provider: pending.map(|(login, _)| login.provider().to_string()),
accepts_empty_input: pending.is_some_and(|(login, _)| login.accepts_empty_input()),
});
}
async fn run_login_command(
handle: &crate::sdk::AgentSessionHandle,
args: &str,
login: &mut Option<Box<DriverLogin>>,
agent_tx: &Sender<PiMsg>,
) {
use crate::interactive::login_flow::{LoginStart, start_login};
let auth_path = crate::config::Config::auth_path();
let models = handle
.session()
.model_registry()
.map(|registry| registry.models().to_vec())
.unwrap_or_default();
match start_login(args, &auth_path, &models, handle.extension_manager()).await {
Ok(LoginStart::Listing(listing)) => {
let _ = agent_tx.send(PiMsg::System(listing));
}
Ok(LoginStart::Pending {
pending,
message,
callback,
}) => {
let pending = Box::new((pending, callback));
let _ = agent_tx.send(PiMsg::System(message));
send_login_pending(agent_tx, Some(&pending));
*login = Some(pending);
}
Err(message) => {
*login = None;
let _ = agent_tx.send(PiMsg::AgentError(message));
send_login_pending(agent_tx, None);
}
}
}
async fn run_login_submit(
handle: &mut crate::sdk::AgentSessionHandle,
input: &str,
login: &mut Option<Box<DriverLogin>>,
agent_tx: &Sender<PiMsg>,
) {
use crate::interactive::login_flow::{LoginFailure, complete_login};
let Some(pending) = login.take() else {
let _ = agent_tx.send(PiMsg::AgentError(String::from(
"No login in progress; run /login <provider>",
)));
send_login_pending(agent_tx, None);
return;
};
let (pending, callback) = *pending;
let auth_path = crate::config::Config::auth_path();
match complete_login(pending, input, &auth_path).await {
Ok((_provider, status)) => {
adopt_stored_credentials(handle, &auth_path);
let _ = agent_tx.send(PiMsg::System(status));
send_login_pending(agent_tx, None);
}
Err(LoginFailure::StillPending(pending, message)) => {
*login = Some(Box::new((pending, callback)));
let _ = agent_tx.send(PiMsg::System(message));
}
Err(LoginFailure::Failed(message)) => {
let _ = agent_tx.send(PiMsg::AgentError(message));
send_login_pending(agent_tx, None);
}
}
}
fn run_logout_command(
handle: &mut crate::sdk::AgentSessionHandle,
args: &str,
agent_tx: &Sender<PiMsg>,
) {
let (active_provider, _) = handle.model();
let auth_path = crate::config::Config::auth_path();
match crate::interactive::login_flow::logout(args, &active_provider, &auth_path) {
Ok((_provider, status)) => {
adopt_stored_credentials(handle, &auth_path);
let _ = agent_tx.send(PiMsg::System(status));
}
Err(err) => {
let _ = agent_tx.send(PiMsg::AgentError(format!("logout: {err}")));
}
}
}
fn adopt_stored_credentials(
handle: &mut crate::sdk::AgentSessionHandle,
auth_path: &std::path::Path,
) {
match crate::auth::AuthStorage::load(auth_path.to_path_buf()) {
Ok(auth) => handle.adopt_auth_storage(auth),
Err(err) => tracing::warn!(
event = "ftui.login.adopt_credentials_failed",
error = %err,
"credentials were saved but could not be reloaded into the live session"
),
}
}
async fn run_set_model_command(
handle: &mut crate::sdk::AgentSessionHandle,
provider: &str,
model: &str,
agent_tx: &Sender<PiMsg>,
) {
let msg = match handle.set_model(provider, model).await {
Ok(()) => {
let label = handle.session().current_model_entry().map_or_else(
|| format!("{provider}/{model}"),
|entry| entry.model.display_label(),
);
PiMsg::System(format!("model set to {label}"))
}
Err(err) => PiMsg::AgentError(format!("model switch: {err}")),
};
let _ = agent_tx.send(msg);
}
async fn run_compact_command(
handle: &mut crate::sdk::AgentSessionHandle,
shake: bool,
agent_tx: &Sender<PiMsg>,
) {
let tx = agent_tx.clone();
let on_event = move |event| {
for msg in agent_event_to_pi_msgs(&event) {
let _ = tx.send(msg);
}
};
let result = if shake {
handle.shake(on_event).await
} else {
handle.compact(on_event).await
};
let (label, done) = if shake {
("shake", "conversation shaken: bulky tool output dropped")
} else {
("compact", "conversation compacted")
};
match result {
Ok(()) => send_conversation_reset(handle, agent_tx, done).await,
Err(err) => {
let _ = agent_tx.send(PiMsg::AgentError(format!("{label}: {err}")));
}
}
}
fn report_replacement_shutdown_failure(
shutdown: &crate::sdk::SessionResourceShutdown,
agent_tx: &Sender<PiMsg>,
) {
let summary = shutdown.failures().collect::<Vec<_>>().join("; ");
let _ = agent_tx.send(PiMsg::AgentError(format!(
"session replacement cancelled because previous-session shutdown preflight failed: {summary}"
)));
}
async fn complete_replacement_after_shutdown(
handle: &mut crate::sdk::AgentSessionHandle,
shutdown: &crate::sdk::SessionResourceShutdown,
) -> std::result::Result<(), String> {
if !shutdown.completed_cleanly() || !shutdown.permits_replacement_mcp_activation() {
handle.disable_mcp();
let issues = shutdown.messages().collect::<Vec<_>>().join("; ");
return Err(if issues.is_empty() {
String::from("previous-session cleanup did not prove complete MCP shutdown")
} else {
issues
});
}
handle.activate_mcp().await;
Ok(())
}
async fn new_session_command(
template: &crate::sdk::SessionOptions,
handle: &mut crate::sdk::AgentSessionHandle,
current_ask: &CurrentAsk,
ext_handler: &Arc<FtuiExtensionUiHandler>,
agent_tx: &Sender<PiMsg>,
runtime_handle: &asupersync::runtime::RuntimeHandle,
) -> std::result::Result<(), String> {
let (provider, model_id) = handle.model();
let mut options = replacement_options(template, ext_handler, runtime_handle);
options.provider = Some(provider.clone());
options.model = Some(model_id.clone());
options.session_path = None;
match crate::sdk::create_agent_session_deferred_mcp(options).await {
Ok(new_handle) => {
let prepared = match handle.preflight_replacement().await {
Ok(prepared) => prepared,
Err(shutdown) => {
report_replacement_shutdown_failure(&shutdown, agent_tx);
let cleanup = new_handle.discard_uncommitted_resources().await;
for issue in cleanup.messages() {
let _ = agent_tx.send(PiMsg::System(format!(
"Uncommitted new session cleanup issue: {issue}"
)));
}
return Ok(());
}
};
let old_handle = std::mem::replace(handle, new_handle);
ext_handler.install_command_registry(handle);
if let Some(ask) = old_handle.ask_tool() {
ask.close_channel_ui();
}
let shutdown = old_handle.commit_resource_shutdown(prepared).await;
if let Err(issues) = complete_replacement_after_shutdown(handle, &shutdown).await {
let _ = agent_tx.send(PiMsg::AgentError(format!(
"Previous-session cleanup was incomplete; ending the FTUI session without activating replacement MCP: {issues}"
)));
return Err(issues);
}
*current_ask
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = handle.ask_tool();
if let Some(ask) = handle.ask_tool() {
drop(install_ask_forwarder(&ask, agent_tx, runtime_handle));
}
let thinking_label = handle.state().await.ok().map_or_else(
|| String::from("off"),
|state| {
state
.thinking_level
.map_or_else(|| String::from("off"), |level| level.to_string())
},
);
send_conversation_reset(
handle,
agent_tx,
&format!(
"Started new session\nModel set to {provider}/{model_id}\nThinking level: {thinking_label}"
),
)
.await;
let label = handle.session().current_model_entry().map_or_else(
|| format!("{provider}/{model_id}"),
|entry| crate::interactive::model_display_label(&entry),
);
let _ = agent_tx.send(PiMsg::TerminalTitle(format!("Pi · {label}")));
}
Err(err) => {
let _ = agent_tx.send(PiMsg::AgentError(format!("new session: {err}")));
}
}
Ok(())
}
async fn run_session_info_command(
handle: &crate::sdk::AgentSessionHandle,
agent_tx: &Sender<PiMsg>,
) {
let state = match handle.state().await {
Ok(state) => state,
Err(err) => {
let _ = agent_tx.send(PiMsg::AgentError(format!("session info: {err}")));
return;
}
};
let model = handle.session().current_model_entry().map_or_else(
|| format!("{}/{}", state.provider, state.model_id),
|entry| crate::interactive::session_model_line(&entry),
);
let info = handle
.with_session(|session| {
let file = session.path.as_ref().map_or_else(
|| String::from("(not saved yet)"),
|p| p.display().to_string(),
);
let name = session.get_name().unwrap_or_else(|| String::from("-"));
format!(
"Session info:\n file: {file}\n id: {id}\n name: {name}\n model: {model}\n thinking: {thinking}\n messageCount: {message_count}",
id = state.session_id.as_deref().unwrap_or("-"),
thinking = state
.thinking_level
.as_ref()
.map_or_else(|| String::from("off"), ToString::to_string),
message_count = state.message_count,
)
})
.await;
match info {
Ok(text) => {
let _ = agent_tx.send(PiMsg::System(text));
}
Err(err) => {
let _ = agent_tx.send(PiMsg::AgentError(format!("session info: {err}")));
}
}
}
async fn run_tree_summary_command(
handle: &crate::sdk::AgentSessionHandle,
agent_tx: &Sender<PiMsg>,
) {
let summary = handle.with_session(|session| {
let leaves = session.list_leaves();
let entry_count = session.entries.len();
if leaves.is_empty() {
return format!("Session tree: no branches, {entry_count} entries");
}
let rendered = leaves
.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.join("\n ");
format!(
"Session tree: {} branch(es), {entry_count} entries\nLeaves:\n {rendered}",
leaves.len()
)
});
match summary.await {
Ok(text) => {
let _ = agent_tx.send(PiMsg::System(text));
}
Err(err) => {
let _ = agent_tx.send(PiMsg::AgentError(format!("tree: {err}")));
}
}
}
fn last_shell_command(session: &crate::session::Session) -> Option<String> {
use crate::session::{SessionEntry, SessionMessage};
session
.entries_for_current_path()
.into_iter()
.rev()
.find_map(|entry| {
let SessionEntry::Message(message) = entry else {
return None;
};
match &message.message {
SessionMessage::BashExecution { command, .. } => Some(command.clone()),
SessionMessage::Assistant { message } => {
message.content.iter().rev().find_map(|block| match block {
crate::model::ContentBlock::ToolCall(call) if call.name == "bash" => call
.arguments
.get("command")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
_ => None,
})
}
_ => None,
}
})
}
fn transcript_text(messages: &[crate::model::Message]) -> String {
use crate::model::{ContentBlock, Message, UserContent};
use std::fmt::Write as _;
fn blocks(out: &mut String, content: &[ContentBlock]) {
for block in content {
match block {
ContentBlock::Text(text) => {
let _ = writeln!(out, "{}", text.text);
}
ContentBlock::Thinking(thinking) => {
let _ = writeln!(out, "[thinking]\n{}", thinking.thinking);
}
ContentBlock::ToolCall(call) => {
let _ = writeln!(out, "[tool call] {} {}", call.name, call.arguments);
}
ContentBlock::Image(image) => {
let _ = writeln!(out, "[image {}]", image.mime_type);
}
ContentBlock::Media(_) => out.push_str("[media]\n"),
ContentBlock::RedactedThinking(_) => out.push_str("[redacted thinking]\n"),
}
}
}
let mut out = String::new();
for message in messages {
match message {
Message::User(user) => {
out.push_str("## User\n");
match &user.content {
UserContent::Text(text) => {
let _ = writeln!(out, "{text}");
}
UserContent::Blocks(content) => blocks(&mut out, content),
}
}
Message::Assistant(assistant) => {
out.push_str("## Assistant\n");
blocks(&mut out, &assistant.content);
}
Message::ToolResult(result) => {
let error = if result.is_error { " (error)" } else { "" };
let _ = writeln!(out, "## Tool result: {}{error}", result.tool_name);
blocks(&mut out, &result.content);
}
Message::Custom(custom) => {
let _ = writeln!(out, "## {}\n{}", custom.custom_type, custom.content);
}
}
out.push('\n');
}
out
}
fn write_private_file(
dir: &std::path::Path,
stem: &str,
contents: &str,
) -> std::io::Result<std::path::PathBuf> {
use std::io::Write as _;
let path = dir.join(format!(
"{stem}_{}.json",
chrono::Utc::now().timestamp_millis()
));
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
options.open(&path)?.write_all(contents.as_bytes())?;
Ok(path)
}
fn run_dump_command(handle: &mut crate::sdk::AgentSessionHandle) -> PiMsg {
let (provider, model_id) = handle.model();
let agent = &mut handle.session_mut().agent;
if agent.messages().is_empty() {
return PiMsg::System(String::from("No messages to dump yet."));
}
let thinking = agent
.stream_options()
.thinking_level
.map_or_else(|| String::from("off"), |level| level.to_string());
let service_tier = agent.stream_options().service_tier.clone();
let mut request = agent.request_context_json();
let tool_names: Vec<String> = request["tools"]
.as_array()
.map(|tools| {
tools
.iter()
.filter_map(|tool| tool["name"].as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let mut text = format!(
"Model: {provider}/{model_id}\nThinking: {thinking}\n{}Tools: {}\n\n",
service_tier
.as_deref()
.map_or_else(String::new, |tier| format!("Service tier: {tier}\n")),
tool_names.join(", ")
);
if let Some(system) = request["systemPrompt"].as_str() {
text.push_str("## System prompt\n");
text.push_str(system);
text.push_str("\n\n");
}
text.push_str(&transcript_text(agent.messages()));
let outcome = crate::interactive::copy_text_to_clipboard(&text);
request["model"] = serde_json::json!(format!("{provider}/{model_id}"));
request["thinkingLevel"] = serde_json::json!(thinking);
request["serviceTier"] = serde_json::json!(service_tier);
let sidecar = serde_json::to_string_pretty(&request)
.map_err(std::io::Error::other)
.and_then(|json| write_private_file(&std::env::temp_dir(), "pi_dump_request", &json));
PiMsg::System(match sidecar {
Ok(path) => format!(
"{outcome}\nLLM request JSON: {}\nThat file stays on disk and may contain raw \
context or secrets; treat it accordingly.",
path.display()
),
Err(err) => format!("{outcome}\n(Could not write the LLM request JSON: {err})"),
})
}
async fn send_message_picker(
handle: &crate::sdk::AgentSessionHandle,
fork: bool,
agent_tx: &Sender<PiMsg>,
) {
let msg = match handle
.with_session(crate::interactive::fork_candidates)
.await
{
Ok(candidates) => PiMsg::MessagePicker {
fork,
messages: candidates
.into_iter()
.map(|candidate| (candidate.summary, candidate.id))
.collect(),
},
Err(err) => PiMsg::AgentError(format!("messages: {err}")),
};
let _ = agent_tx.send(msg);
}
async fn run_rewind_command(
handle: &mut crate::sdk::AgentSessionHandle,
entry_id: &str,
agent_tx: &Sender<PiMsg>,
) {
let prepared = match handle.rewind_to_user_message(entry_id).await {
Ok(prepared) => prepared,
Err(err) => {
let _ = agent_tx.send(PiMsg::AgentError(format!("rewind: {err}")));
return;
}
};
let status = if prepared.dropped_images == 0 {
String::from("Rewound; the old path is kept as a branch (/tree)")
} else {
format!(
"Rewound; the old path is kept as a branch (/tree). The message's {} image(s) \
were not restored: attach them again with @path",
prepared.dropped_images
)
};
send_conversation_reset(handle, agent_tx, &status).await;
if let Ok(session_id) = handle
.with_session(|session| session.header.id.clone())
.await
{
let _ = agent_tx.send(PiMsg::SetEditorText {
owner_session_id: session_id,
text: prepared.text,
});
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToggleRequest {
Toggle,
On,
Off,
Status,
}
impl ToggleRequest {
fn parse(arg: &str) -> Option<Self> {
match arg.trim().to_ascii_lowercase().as_str() {
"" | "toggle" => Some(Self::Toggle),
"on" => Some(Self::On),
"off" => Some(Self::Off),
"status" => Some(Self::Status),
_ => None,
}
}
}
fn run_skillful_command(
handle: &mut crate::sdk::AgentSessionHandle,
block: Option<&str>,
request: ToggleRequest,
) -> PiMsg {
let Some(block) = block.filter(|block| !block.trim().is_empty()) else {
return PiMsg::System(String::from(
"No skills are loaded, so there is nothing to list.",
));
};
let agent = &mut handle.session_mut().agent;
let prompt = agent.system_prompt().unwrap_or_default().to_string();
let listed = prompt.contains(block);
let want = match request {
ToggleRequest::Toggle => !listed,
ToggleRequest::On => true,
ToggleRequest::Off => false,
ToggleRequest::Status => {
return PiMsg::System(format!(
"Skill listing: {} for this session.",
if listed { "on" } else { "off" }
));
}
};
if want && !listed {
agent.set_system_prompt(Some(format!("{prompt}{block}")));
} else if !want && listed {
agent.set_system_prompt(Some(prompt.replacen(block, "", 1)));
}
PiMsg::System(format!(
"Skill listing {} for this session.",
if want { "enabled" } else { "disabled" }
))
}
fn fast_mode_realization(provider: &str) -> Option<&'static str> {
match provider {
"openai" | "openai-codex" | "openrouter" => Some("service_tier=priority"),
"anthropic" => Some("speed=fast, on models that offer it"),
_ => None,
}
}
fn run_fast_command(handle: &mut crate::sdk::AgentSessionHandle, request: ToggleRequest) -> PiMsg {
let (provider, _) = handle.model();
let realization = fast_mode_realization(&provider);
let options = handle.session_mut().agent.stream_options_mut();
let enabled = options.service_tier.as_deref() == Some("priority");
let enable = match request {
ToggleRequest::Toggle => !enabled,
ToggleRequest::On => true,
ToggleRequest::Off => false,
ToggleRequest::Status => {
return PiMsg::System(match (enabled, realization) {
(true, Some(how)) => format!("Fast mode is on ({provider}: {how})."),
(true, None) => format!("Fast mode is on, but {provider} has no priority tier."),
(false, _) => String::from("Fast mode is off."),
});
}
};
if !enable {
options.service_tier = None;
return PiMsg::System(String::from("Fast mode disabled."));
}
let Some(how) = realization else {
return PiMsg::System(format!(
"Fast mode is unavailable for {provider}: only OpenAI, Codex, OpenRouter and \
Anthropic models have a priority tier."
));
};
options.service_tier = Some(String::from("priority"));
PiMsg::System(format!(
"Fast mode enabled ({provider}: {how}). Priority processing costs more."
))
}
async fn run_set_thinking_command(
handle: &mut crate::sdk::AgentSessionHandle,
level: Option<crate::model::ThinkingLevel>,
agent_tx: &Sender<PiMsg>,
) {
let msg = match level {
None => match handle.state().await {
Ok(state) => PiMsg::System(format!(
"Thinking level: {}",
state
.thinking_level
.as_ref()
.map_or_else(|| String::from("off"), ToString::to_string)
)),
Err(err) => PiMsg::AgentError(format!("thinking: {err}")),
},
Some(level) => match handle.set_thinking_level(level).await {
Ok(()) => PiMsg::System(format!("Thinking level: {level}")),
Err(err) => PiMsg::AgentError(format!("thinking: {err}")),
},
};
let _ = agent_tx.send(msg);
}
async fn send_status_snapshot(
handle: &crate::sdk::AgentSessionHandle,
cwd: &std::path::Path,
agent_tx: &Sender<PiMsg>,
) {
let entry = handle.session().current_model_entry();
let (_, model_id) = handle.model();
let model = entry
.as_ref()
.map_or(model_id, |entry| entry.model.status_label());
let context_window = entry.as_ref().map(|entry| entry.model.context_window);
let mode = match handle.session().agent.plan_state().mode() {
crate::plan::PlanMode::Off => String::from("act"),
other => other.as_str().to_string(),
};
let totals = handle
.with_session(|session| {
let (_, usage) = crate::interactive::conversation_from_session(session);
let last_prompt = session
.to_messages_for_current_path()
.iter()
.rev()
.find_map(|message| match message {
crate::model::Message::Assistant(assistant) => Some(
assistant.usage.input
+ assistant.usage.cache_read
+ assistant.usage.cache_write,
),
_ => None,
})
.unwrap_or(0);
(usage, last_prompt, session.get_name().unwrap_or_default())
})
.await;
let Ok((usage, last_prompt, session_name)) = totals else {
return;
};
let _ = agent_tx.send(PiMsg::StatusSnapshot(
crate::interactive::FtuiStatusSnapshot {
model,
thinking: handle.thinking_level().map(|level| level.to_string()),
mode,
cwd: home_relative(cwd),
vcs: crate::interactive::read_vcs_info(cwd),
context_pct: context_percent(last_prompt, context_window),
cost_usd: usage.cost.total,
tokens: usage.input + usage.output,
session_name,
},
));
}
fn render_powerline(snapshot: &crate::interactive::FtuiStatusSnapshot, width: usize) -> String {
let ctx = crate::status_line::StatusContext {
model: &snapshot.model,
thinking_level: snapshot.thinking.as_deref(),
mode: &snapshot.mode,
cwd: &snapshot.cwd,
git_branch: snapshot.vcs.as_deref(),
git_dirty: false,
context_pct: snapshot.context_pct,
cost_usd: snapshot.cost_usd,
tokens_used: snapshot.tokens,
subagent_count: 0,
session_name: &snapshot.session_name,
timestamp_str: "",
};
crate::status_line::PowerlineStatusLine::with_preset(
crate::status_line::StatusLinePreset::Default,
)
.render(&ctx, width)
}
fn home_relative(cwd: &std::path::Path) -> String {
dirs::home_dir()
.and_then(|home| {
cwd.strip_prefix(&home)
.ok()
.map(std::path::Path::to_path_buf)
})
.map_or_else(
|| cwd.display().to_string(),
|relative| {
if relative.as_os_str().is_empty() {
String::from("~")
} else {
format!("~{}{}", std::path::MAIN_SEPARATOR, relative.display())
}
},
)
}
fn context_percent(used: u64, window: Option<u32>) -> u8 {
let Some(window) = window.filter(|window| *window > 0) else {
return 0;
};
u8::try_from((used.saturating_mul(100) / u64::from(window)).min(100)).unwrap_or(100)
}
fn next_cycle_model(models: &[String], current: &str, forward: bool) -> Option<String> {
let mut ordered = models.to_vec();
ordered.sort_by_key(|model| model.to_ascii_lowercase());
ordered.dedup_by(|left, right| left.eq_ignore_ascii_case(right));
let position = ordered
.iter()
.position(|model| model.eq_ignore_ascii_case(current));
let next = match (position, forward) {
(Some(index), true) => ordered.get((index + 1) % ordered.len())?,
(Some(index), false) => ordered.get((index + ordered.len() - 1) % ordered.len())?,
(None, true) => ordered.first()?,
(None, false) => ordered.last()?,
};
(!next.eq_ignore_ascii_case(current)).then(|| next.clone())
}
fn scope_models(patterns: &[String], available: &[String]) -> Result<Vec<String>, String> {
let mut resolved: Vec<String> = Vec::new();
for pattern in patterns {
let raw = crate::interactive::strip_thinking_level_suffix(pattern).to_ascii_lowercase();
let glob = if raw.contains(['*', '?', '[']) {
Some(
glob::Pattern::new(&raw)
.map_err(|err| format!("Invalid model pattern \"{pattern}\": {err}"))?,
)
} else {
None
};
for model in available {
let full = model.to_ascii_lowercase();
let id = full.split_once('/').map_or(full.as_str(), |(_, id)| id);
let hit = glob.as_ref().map_or_else(
|| raw == full || raw == id,
|glob| glob.matches(&full) || glob.matches(id),
);
if hit && !resolved.iter().any(|m| m.eq_ignore_ascii_case(model)) {
resolved.push(model.clone());
}
}
}
Ok(resolved)
}
fn run_scoped_models_command(
args: &str,
available: &[String],
cycle: &mut Vec<String>,
cwd: &std::path::Path,
) -> PiMsg {
const USAGE: &str = "Usage: /scoped-models [patterns|clear] (e.g. gpt-5*,claude-sonnet*)";
let args = args.trim();
if args.is_empty() {
return PiMsg::System(format!(
"Scoped models: ctrl+p cycles {} model(s): {}",
cycle.len(),
cycle.join(", ")
));
}
let patterns = if args.eq_ignore_ascii_case("clear") {
Vec::new()
} else {
let patterns = crate::interactive::parse_scoped_model_patterns(args);
if patterns.is_empty() {
return PiMsg::System(String::from(USAGE));
}
patterns
};
let next = if patterns.is_empty() {
available.to_vec()
} else {
match scope_models(&patterns, available) {
Ok(models) if models.is_empty() => {
return PiMsg::System(format!(
"No models matched {}; the ctrl+p cycle is unchanged.",
patterns.join(", ")
));
}
Ok(models) => models,
Err(err) => return PiMsg::AgentError(err),
}
};
*cycle = next;
let mut message = if patterns.is_empty() {
format!(
"Scoped models cleared: ctrl+p cycles all {} models.",
cycle.len()
)
} else {
format!(
"Scoped models: ctrl+p cycles {} model(s): {}",
cycle.len(),
cycle.join(", ")
)
};
if let Err(err) = crate::config::Config::patch_settings_with_roots(
crate::config::SettingsScope::Project,
&crate::config::Config::global_dir(),
cwd,
serde_json::json!({ "enabled_models": patterns }),
) {
let _ = write!(message, " (not saved: {err})");
}
PiMsg::System(message)
}
async fn run_cycle_model_command(
handle: &mut crate::sdk::AgentSessionHandle,
models: &[String],
forward: bool,
agent_tx: &Sender<PiMsg>,
) {
let (provider, model_id) = handle.model();
let current = format!("{provider}/{model_id}");
let Some(next) = next_cycle_model(models, ¤t, forward) else {
let message = if models.is_empty() {
"No models available"
} else {
"Only one model available"
};
let _ = agent_tx.send(PiMsg::System(String::from(message)));
return;
};
let Some((provider, model)) = next.split_once('/') else {
let _ = agent_tx.send(PiMsg::AgentError(format!(
"model cycle: {next} is not provider/model"
)));
return;
};
run_set_model_command(handle, provider, model, agent_tx).await;
}
async fn run_cycle_thinking_command(
handle: &mut crate::sdk::AgentSessionHandle,
agent_tx: &Sender<PiMsg>,
) {
let msg = match handle.cycle_thinking_level().await {
Ok(Some(level)) => PiMsg::System(format!("Thinking level: {level}")),
Ok(None) => PiMsg::System(String::from("Current model does not support thinking")),
Err(err) => PiMsg::AgentError(format!("thinking: {err}")),
};
let _ = agent_tx.send(msg);
}
async fn run_export_command(
handle: &crate::sdk::AgentSessionHandle,
cwd: &std::path::Path,
raw_path: &str,
agent_tx: &Sender<PiMsg>,
) {
let cx = crate::agent_cx::AgentCx::for_request();
let (output_path, html) = {
let store = handle.session_store();
let Ok(session) = store.lock(cx.cx()).await else {
let _ = agent_tx.send(PiMsg::AgentError(String::from(
"export: session busy; try again",
)));
return;
};
let output_path = if raw_path.trim().is_empty() {
crate::interactive::default_export_path(cwd, &session)
} else {
crate::interactive::resolve_output_path(cwd, raw_path)
};
(output_path, session.to_html())
};
if let Some(parent) = output_path.parent()
&& !parent.as_os_str().is_empty()
&& let Err(err) = std::fs::create_dir_all(parent)
{
let _ = agent_tx.send(PiMsg::AgentError(format!(
"export: failed to create dir: {err}"
)));
return;
}
let message = match std::fs::write(&output_path, html) {
Ok(()) => PiMsg::System(format!("Exported HTML: {}", output_path.display())),
Err(err) => PiMsg::AgentError(format!("export: failed to write: {err}")),
};
let _ = agent_tx.send(message);
}
async fn run_set_name_command(
handle: &mut crate::sdk::AgentSessionHandle,
name: &str,
agent_tx: &Sender<PiMsg>,
) {
let msg = match handle.set_session_name(name).await {
Ok(()) => {
let _ = agent_tx.send(PiMsg::TerminalTitle(format!("Pi · {name}")));
PiMsg::System(format!("Session name: {name}"))
}
Err(err) => PiMsg::AgentError(format!("name: {err}")),
};
let _ = agent_tx.send(msg);
}
async fn run_add_dir_command(
handle: &mut crate::sdk::AgentSessionHandle,
dir: &str,
agent_tx: &Sender<PiMsg>,
) {
if dir.trim().is_empty() {
let _ = agent_tx.send(PiMsg::AgentError(String::from(
"usage: /add-dir <directory>",
)));
return;
}
let msg = match handle.add_workspace_root(dir).await {
Ok(status) => PiMsg::System(status),
Err(err) => PiMsg::AgentError(format!("add-dir: {err}")),
};
let _ = agent_tx.send(msg);
}
async fn run_remove_dir_command(
handle: &mut crate::sdk::AgentSessionHandle,
dir: &str,
agent_tx: &Sender<PiMsg>,
) {
if dir.trim().is_empty() {
let _ = agent_tx.send(PiMsg::AgentError(String::from(
"usage: /remove-dir <directory>",
)));
return;
}
let msg = match handle.remove_workspace_root(dir).await {
Ok(status) => PiMsg::System(status),
Err(err) => PiMsg::AgentError(format!("remove-dir: {err}")),
};
let _ = agent_tx.send(msg);
}
fn run_crash_command(action: &str, agent_tx: &Sender<PiMsg>) {
let agent_dir = crate::config::Config::global_dir();
let msg = match action {
"" | "list" => {
let bundles = pi::crash::list_bundles(&agent_dir);
if bundles.is_empty() {
PiMsg::System(String::from("No crash bundles recorded."))
} else {
PiMsg::System(
bundles
.iter()
.map(|b| {
format!(
"{} {} {}{}",
b.created_at,
b.kind,
b.dir.display(),
if b.noticed { "" } else { " (new)" }
)
})
.collect::<Vec<_>>()
.join("\n"),
)
}
}
"show" => pi::crash::show_latest(&agent_dir).map_or_else(
|| PiMsg::System(String::from("No crash bundles recorded.")),
PiMsg::System,
),
"delete" => {
let removed = pi::crash::delete_all(&agent_dir);
PiMsg::System(format!("Deleted {removed} crash bundle(s)"))
}
other => PiMsg::AgentError(format!("usage: /crash [list|show|delete] (got: {other})")),
};
let _ = agent_tx.send(msg);
}
fn run_undo_command(
handle: &crate::sdk::AgentSessionHandle,
count: usize,
force: bool,
redo: bool,
agent_tx: &Sender<PiMsg>,
) {
let verb = if redo { "redo" } else { "undo" };
let Some(recorder) = handle.session().agent.mutation_recorder() else {
let _ = agent_tx.send(PiMsg::AgentError(format!(
"/{verb} unavailable: no mutation recorder in this session"
)));
return;
};
let outcome = if redo {
recorder.redo(count, force)
} else {
recorder.undo(count, force)
};
let _ = agent_tx.send(PiMsg::System(crate::undo::render_outcome_text(
&outcome, redo, count,
)));
}
async fn run_usage_command(cwd: &std::path::Path, refresh: bool, agent_tx: &Sender<PiMsg>) {
let auth =
crate::auth::AuthStorage::load(crate::config::Config::auth_path()).and_then(|mut auth| {
auth.set_command_working_directory(cwd)?;
Ok(auth)
});
let message = match auth {
Ok(auth) => {
let rows = crate::usage::gather_usage(&auth, refresh).await;
crate::usage::render_usage_text(&rows)
}
Err(err) => format!("failed to load credentials: {err}"),
};
let _ = agent_tx.send(PiMsg::System(message));
}
fn build_fork_session(
source: &crate::session::Session,
entry_id: &str,
provider: String,
model_id: String,
) -> crate::error::Result<(crate::session::Session, String)> {
let plan = source.plan_fork_from_user_message(entry_id)?;
let selected_text = plan.selected_text.clone();
let mut forked = crate::session::Session::create_with_dir(source.session_dir.clone());
forked.header.provider = Some(provider);
forked.header.model_id = Some(model_id);
forked
.header
.thinking_level
.clone_from(&source.header.thinking_level);
forked.init_from_fork_plan(plan);
Ok((forked, selected_text))
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
async fn fork_session_command(
args: &str,
template: &crate::sdk::SessionOptions,
handle: &mut crate::sdk::AgentSessionHandle,
current_ask: &CurrentAsk,
ext_handler: &Arc<FtuiExtensionUiHandler>,
agent_tx: &Sender<PiMsg>,
runtime_handle: &asupersync::runtime::RuntimeHandle,
) -> std::result::Result<(), String> {
use crate::extensions::{EXTENSION_EVENT_TIMEOUT_MS, ExtensionEventName};
let args = args.trim();
let snapshot = handle
.with_session(|session| {
let mut source = session.clone();
source.ensure_entry_ids();
let candidates = crate::interactive::fork_candidates(&source);
(source, candidates)
})
.await;
let (source, candidates) = match snapshot {
Ok(snapshot) => snapshot,
Err(err) => {
let _ = agent_tx.send(PiMsg::AgentError(format!("fork: {err}")));
return Ok(());
}
};
if args.eq_ignore_ascii_case("list") || args.eq_ignore_ascii_case("ls") {
let text = if candidates.is_empty() {
String::from("No user messages to fork from")
} else {
crate::interactive::format_fork_candidates(&candidates)
};
let _ = agent_tx.send(PiMsg::System(text));
return Ok(());
}
let selection = match crate::interactive::select_fork_candidate(&candidates, args) {
Ok(selection) => selection,
Err(message) => {
let _ = agent_tx.send(PiMsg::AgentError(message));
return Ok(());
}
};
let session_id = source.header.id.clone();
if let Some(manager) = handle.extension_manager() {
let cancelled = manager
.dispatch_cancellable_event(
ExtensionEventName::SessionBeforeFork,
Some(serde_json::json!({
"entryId": selection.id,
"summary": selection.summary,
"sessionId": session_id,
})),
EXTENSION_EVENT_TIMEOUT_MS,
)
.await
.unwrap_or(false);
if cancelled {
let _ = agent_tx.send(PiMsg::System(String::from("Fork cancelled by extension")));
return Ok(());
}
}
let (provider, model_id) = handle.model();
let (mut forked, selected_text) =
match build_fork_session(&source, &selection.id, provider, model_id) {
Ok(built) => built,
Err(err) => {
let _ = agent_tx.send(PiMsg::AgentError(format!("Failed to build fork: {err}")));
return Ok(());
}
};
let new_session_id = forked.header.id.clone();
if let Err(err) = forked.save().await {
let _ = agent_tx.send(PiMsg::AgentError(format!("Failed to save fork: {err}")));
return Ok(());
}
let Some(path) = forked.path.clone() else {
let _ = agent_tx.send(PiMsg::AgentError(String::from(
"Failed to save fork: the session has no file",
)));
return Ok(());
};
Box::pin(resume_session_command(
&path.to_string_lossy(),
template,
handle,
current_ask,
ext_handler,
agent_tx,
runtime_handle,
))
.await?;
let switched = handle
.with_session(|session| session.header.id == new_session_id)
.await
.unwrap_or(false);
if switched {
let _ = agent_tx.send(PiMsg::System(format!(
"Forked new session from {}",
selection.summary
)));
let _ = agent_tx.send(PiMsg::SetEditorText {
owner_session_id: new_session_id.clone(),
text: selected_text,
});
if let Some(manager) = handle.extension_manager() {
let _ = manager
.dispatch_event(
ExtensionEventName::SessionFork,
Some(serde_json::json!({
"entryId": selection.id,
"summary": selection.summary,
"sessionId": session_id,
"newSessionId": new_session_id,
})),
)
.await;
}
}
Ok(())
}
async fn reload_session_command(
template: &crate::sdk::SessionOptions,
handle: &mut crate::sdk::AgentSessionHandle,
current_ask: &CurrentAsk,
ext_handler: &Arc<FtuiExtensionUiHandler>,
agent_tx: &Sender<PiMsg>,
runtime_handle: &asupersync::runtime::RuntimeHandle,
) -> std::result::Result<(), String> {
let snapshot = handle
.with_session(|session| {
let saved = session.path.clone().filter(|path| path.exists());
(saved, session.entries.is_empty())
})
.await;
let (saved, empty) = match snapshot {
Ok(snapshot) => snapshot,
Err(err) => {
let _ = agent_tx.send(PiMsg::AgentError(format!("reload: {err}")));
return Ok(());
}
};
match saved {
Some(path) => {
Box::pin(resume_session_command(
&path.to_string_lossy(),
template,
handle,
current_ask,
ext_handler,
agent_tx,
runtime_handle,
))
.await?;
}
None if empty => {
Box::pin(new_session_command(
template,
handle,
current_ask,
ext_handler,
agent_tx,
runtime_handle,
))
.await?;
}
None => {
let _ = agent_tx.send(PiMsg::AgentError(String::from(
"reload: this conversation is not saved to a session file, so reloading would \
lose it. Start a saved session (/new) to reload resources.",
)));
return Ok(());
}
}
let _ = agent_tx.send(PiMsg::System(String::from(
"Reloaded extensions, skills, prompt templates, themes and context files.",
)));
Ok(())
}
async fn resume_session_command(
path: &str,
template: &crate::sdk::SessionOptions,
handle: &mut crate::sdk::AgentSessionHandle,
current_ask: &CurrentAsk,
ext_handler: &Arc<FtuiExtensionUiHandler>,
agent_tx: &Sender<PiMsg>,
runtime_handle: &asupersync::runtime::RuntimeHandle,
) -> std::result::Result<(), String> {
let mut options = replacement_options(template, ext_handler, runtime_handle);
options.session_path = Some(std::path::PathBuf::from(path));
match crate::sdk::create_agent_session_deferred_mcp(options).await {
Ok(new_handle) => {
let prepared = match handle.preflight_replacement().await {
Ok(prepared) => prepared,
Err(shutdown) => {
report_replacement_shutdown_failure(&shutdown, agent_tx);
let cleanup = new_handle.discard_uncommitted_resources().await;
for issue in cleanup.messages() {
let _ = agent_tx.send(PiMsg::System(format!(
"Uncommitted resumed session cleanup issue: {issue}"
)));
}
return Ok(());
}
};
let old_handle = std::mem::replace(handle, new_handle);
ext_handler.install_command_registry(handle);
if let Some(ask) = old_handle.ask_tool() {
ask.close_channel_ui();
}
let shutdown = old_handle.commit_resource_shutdown(prepared).await;
if let Err(issues) = complete_replacement_after_shutdown(handle, &shutdown).await {
let _ = agent_tx.send(PiMsg::AgentError(format!(
"Previous-session cleanup was incomplete; ending the FTUI session without activating replacement MCP: {issues}"
)));
return Err(issues);
}
*current_ask
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = handle.ask_tool();
if let Some(ask) = handle.ask_tool() {
drop(install_ask_forwarder(&ask, agent_tx, runtime_handle));
}
send_conversation_reset(handle, agent_tx, "session resumed").await;
if let Ok(Some(name)) = handle.with_session(crate::session::Session::get_name).await {
let _ = agent_tx.send(PiMsg::TerminalTitle(format!("Pi · {name}")));
}
}
Err(err) => {
let _ = agent_tx.send(PiMsg::AgentError(format!("resume: {err}")));
}
}
Ok(())
}
async fn prepare_retry_turn(
handle: &mut crate::sdk::AgentSessionHandle,
agent_tx: &Sender<PiMsg>,
) -> Option<crate::model::UserContent> {
let content = match handle.prepare_retry_content().await {
Ok(content) => content,
Err(err) => {
let _ = agent_tx.send(PiMsg::AgentError(format!("retry: {err}")));
return None;
}
};
let text = match &content {
crate::model::UserContent::Text(text) => text.clone(),
crate::model::UserContent::Blocks(blocks) => prompt_display("", blocks),
};
let snapshot = handle
.with_session(|session| {
let (messages, usage) = crate::interactive::conversation_from_session(session);
(session.header.id.clone(), messages, usage)
})
.await;
match snapshot {
Ok((session_id, messages, usage)) => {
let _ = agent_tx.send(PiMsg::RetryCommitted {
session_id,
messages,
usage,
text,
status: Some(String::from("Retrying last turn")),
});
Some(content)
}
Err(err) => {
let _ = agent_tx.send(PiMsg::AgentError(format!("retry: {err}")));
None
}
}
}
async fn run_retry_turn(
handle: &mut crate::sdk::AgentSessionHandle,
agent_tx: &Sender<PiMsg>,
turn_control: &TurnControlSlot,
) {
let Some(content) = prepare_retry_turn(handle, agent_tx).await else {
return;
};
let (text, attachments) = match content {
crate::model::UserContent::Text(text) => (text, Vec::new()),
crate::model::UserContent::Blocks(blocks) => {
handle
.session_mut()
.agent
.set_magic_keyword_scan_override(Some(String::new()));
(String::new(), blocks)
}
};
run_prompt_turn(handle, text, attachments, agent_tx, turn_control).await;
handle
.session_mut()
.agent
.set_magic_keyword_scan_override(None);
}
async fn send_conversation_reset(
handle: &crate::sdk::AgentSessionHandle,
agent_tx: &Sender<PiMsg>,
status: &str,
) {
match handle
.with_session(|session| {
let session_id = session.header.id.clone();
let (messages, usage) = crate::interactive::conversation_from_session(session);
(session_id, messages, usage)
})
.await
{
Ok((session_id, messages, usage)) => {
let _ = agent_tx.send(PiMsg::ConversationReset {
session_id,
messages,
usage,
status: Some(status.to_string()),
});
}
Err(err) => {
let _ = agent_tx.send(PiMsg::AgentError(format!("conversation snapshot: {err}")));
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct BashUiShell {
path: Option<String>,
command_prefix: Option<String>,
}
impl BashUiShell {
fn from_config(config: &crate::config::Config) -> Self {
Self {
path: config.shell_path.clone(),
command_prefix: config.shell_command_prefix.clone(),
}
}
}
async fn run_bash_ui_command(
cwd: &std::path::Path,
shell: &BashUiShell,
command: &str,
exclude: bool,
agent_tx: &Sender<PiMsg>,
) -> Option<String> {
let _ = agent_tx.send(PiMsg::AgentStart);
let _ = agent_tx.send(PiMsg::ToolStart {
name: String::from("bash"),
tool_id: String::from("ftui-bash"),
});
let result = crate::tools::run_bash_command(
cwd,
shell.path.as_deref(),
shell.command_prefix.as_deref(),
command,
None,
None,
)
.await;
let output = match result {
Ok(result) => {
let display = crate::session::bash_execution_to_text(
command,
&result.output,
result.exit_code,
result.cancelled,
result.truncated,
result.full_output_path.as_deref(),
);
let mut shown = display.clone();
if exclude {
shown.push_str("\n\n[Output excluded from model context]");
}
let _ = agent_tx.send(PiMsg::BashResult {
display: shown,
content_for_agent: None,
});
Some(display)
}
Err(err) => {
let _ = agent_tx.send(PiMsg::ToolEnd {
name: String::from("bash"),
tool_id: String::from("ftui-bash"),
is_error: true,
output: None,
});
let _ = agent_tx.send(PiMsg::AgentError(format!("bash: {err}")));
None
}
};
if output.is_some() {
let _ = agent_tx.send(PiMsg::ToolEnd {
name: String::from("bash"),
tool_id: String::from("ftui-bash"),
is_error: false,
output: None,
});
}
let _ = agent_tx.send(PiMsg::AgentDone {
usage: None,
stop_reason: crate::model::StopReason::Stop,
error_message: None,
});
output
}
async fn create_driver_session(
mut session_options: crate::sdk::SessionOptions,
agent_tx: &Sender<PiMsg>,
ext_reply_rx: std::sync::mpsc::Receiver<ExtensionUiResponse>,
runtime_handle: &asupersync::runtime::RuntimeHandle,
command_registry: &ExtensionCommandRegistry,
) -> Option<(crate::sdk::AgentSessionHandle, Arc<FtuiExtensionUiHandler>)> {
let ext_handler = Arc::new(FtuiExtensionUiHandler {
command_registry: Arc::downgrade(command_registry),
..FtuiExtensionUiHandler::new(agent_tx.clone())
});
session_options.extension_ui_handler =
Some(Arc::clone(&ext_handler) as Arc<dyn crate::sdk::ExtensionUiHandler>);
session_options.runtime_handle = Some(runtime_handle.clone());
spawn_ext_reply_pump(Arc::clone(&ext_handler), ext_reply_rx, runtime_handle);
match crate::sdk::create_agent_session(session_options).await {
Ok(handle) => {
ext_handler.install_command_registry(&handle);
Some((handle, ext_handler))
}
Err(err) => {
let _ = agent_tx.send(PiMsg::AgentError(format!("session: {err}")));
None
}
}
}
async fn prepare_driver_session_options(
mut options: crate::sdk::SessionOptions,
) -> crate::error::Result<crate::sdk::SessionOptions> {
let process_cwd = std::env::current_dir()?;
let resolve = |path: &std::path::Path, base: &std::path::Path| {
if path.is_absolute() {
path.to_path_buf()
} else {
base.join(path)
}
};
let requested_cwd = options
.working_directory
.as_deref()
.map_or_else(|| process_cwd.clone(), |path| resolve(path, &process_cwd));
options.session_path = options
.session_path
.as_deref()
.map(|path| resolve(path, &requested_cwd));
options.session_dir = options
.session_dir
.as_deref()
.map(|path| resolve(path, &requested_cwd));
if options.working_directory.is_some() && options.workspace.is_some() {
options.working_directory = Some(requested_cwd);
return Ok(options);
}
let mut cwd = requested_cwd.clone();
let mut additional_roots = Vec::new();
if !options.no_session
&& let Some(path) = options.session_path.as_deref()
&& crate::session::session_path_entry_exists(path)?
{
let saved = crate::session_workdir::inspect_saved_session_workdir(path).await?;
cwd = saved.resolve_runtime_cwd(
options
.working_directory
.as_ref()
.map(|_| requested_cwd.as_path()),
)?;
if let Some(workspace) = &options.workspace {
saved.check_runtime_cwd(workspace.snapshot_or(&cwd).primary())?;
}
let source = saved.source_path.to_str().ok_or_else(|| {
crate::error::Error::session(
"PI_SESSION_WORKDIR_INVALID_ENCODING: session path must be UTF-8",
)
})?;
let session = crate::session::Session::open(source).await?;
saved.check_loaded_session(&session)?;
saved.check_runtime_cwd(&cwd)?;
additional_roots = session.additional_roots();
options.session_path = Some(saved.source_path);
}
if options.workspace.is_none() {
let mut workspace = crate::workspace::WorkspaceHandle::single(&cwd);
for root in additional_roots {
if !root.is_absolute() {
tracing::warn!(path = %root.display(), "Skipping non-absolute saved workspace root");
continue;
}
match crate::workspace::validate_new_root(&root) {
Ok(canonical) => workspace.add_root(&canonical),
Err(error) => tracing::warn!(%error, "Skipping unavailable saved workspace root"),
}
}
options.workspace = Some(workspace);
}
options.working_directory = Some(cwd);
Ok(options)
}
fn driver_bash_cwd(session_options: &crate::sdk::SessionOptions) -> std::path::PathBuf {
session_options
.working_directory
.clone()
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| std::path::PathBuf::from("."))
}
fn finish_ftui_run(
app_result: std::io::Result<()>,
driver_result: std::thread::Result<std::io::Result<()>>,
) -> std::io::Result<()> {
app_result?;
driver_result
.map_err(|_| std::io::Error::other("FTUI agent driver panicked during shutdown"))?
}
fn terminal_replacement_error(
agent_tx: &Sender<PiMsg>,
replacement_failure: String,
shutdown: &crate::sdk::SessionResourceShutdown,
) -> std::io::Error {
let _ = agent_tx.send(PiMsg::UiShutdown);
let cleanup_issues = shutdown.failures().collect::<Vec<_>>().join("; ");
let detail = if cleanup_issues.is_empty() {
replacement_failure
} else {
format!("{replacement_failure}; replacement cleanup was also incomplete: {cleanup_issues}")
};
std::io::Error::other(format!(
"FTUI session replacement failed terminally: {detail}"
))
}
pub struct FtuiSettings {
pub markdown_spacing: crate::config::MarkdownSpacing,
pub gh_path: Option<String>,
pub disable_mouse_capture: bool,
pub subagent_role_spec: Option<String>,
pub cycle_models: Vec<String>,
pub btw_client: Option<Arc<crate::btw::BtwClient>>,
pub title_client: Option<Arc<crate::session_title::TitleClient>>,
pub hide_thinking_block: bool,
pub double_escape_action: DoubleEscapeAction,
pub model_names: HashMap<String, String>,
pub start_in_plan_mode: bool,
pub initial_content: Option<crate::app::InitialMessage>,
pub initial_messages: Vec<String>,
}
fn queue_initial_commands(
submit_tx: &Sender<UiCommand>,
start_in_plan_mode: bool,
has_initial_content: bool,
messages: Vec<String>,
) -> std::io::Result<usize> {
let send = |command| {
submit_tx
.send(command)
.map_err(|_| std::io::Error::other("FTUI startup command queue closed"))
};
if start_in_plan_mode {
send(UiCommand::Plan {
action: String::from("enter"),
})?;
}
if has_initial_content {
send(UiCommand::InitialPrompt)?;
}
let mut count = 0;
for message in messages {
if !message.trim().is_empty() {
send(UiCommand::Prompt(message))?;
count += 1;
}
}
Ok(count)
}
#[allow(clippy::too_many_lines)]
pub fn run(
session_options: crate::sdk::SessionOptions,
theme: &crate::theme::Theme,
inline: bool,
available_models: Vec<String>,
available_sessions: Vec<(String, String)>,
settings: FtuiSettings,
mut autocomplete: AutocompleteLaunch,
) -> std::io::Result<()> {
const DRIVER_STACK_BYTES: usize = 16 * 1024 * 1024;
let supplied_workspace =
session_options.working_directory.is_some() && session_options.workspace.is_some();
let session_options =
futures::executor::block_on(prepare_driver_session_options(session_options))
.map_err(std::io::Error::other)?;
let bash_cwd = driver_bash_cwd(&session_options);
let workspace = session_options.workspace.clone();
autocomplete.cwd.clone_from(&bash_cwd);
if !supplied_workspace && let Some(source) = &mut autocomplete.resource_source {
source.package_manager = crate::package_manager::PackageManager::new(bash_cwd.clone())
.with_project_trust(session_options.workspace_trusted);
}
let FtuiSettings {
markdown_spacing,
gh_path,
disable_mouse_capture,
subagent_role_spec,
cycle_models,
btw_client,
title_client,
hide_thinking_block,
double_escape_action,
model_names,
start_in_plan_mode,
mut initial_content,
initial_messages,
} = settings;
let driver_btw_client = btw_client.clone();
let mut cycle_models = if cycle_models.is_empty() {
available_models.clone()
} else {
cycle_models
};
let driver_available_models = available_models.clone();
let restart_requested = Arc::new(std::sync::atomic::AtomicBool::new(false));
let restart_session: Arc<Mutex<Option<std::path::PathBuf>>> = Arc::default();
let driver_restart_requested = Arc::clone(&restart_requested);
let driver_restart_session = Arc::clone(&restart_session);
let driver_catalog = autocomplete.catalog.clone();
let mut driver_resources = autocomplete.resources.clone();
let mut resource_source = autocomplete.resource_source.clone();
let mut auto_resize_images = resource_source
.as_ref()
.is_none_or(|source| source.config.image_auto_resize());
let mut settings_config = resource_source
.as_ref()
.map(|source| source.config.clone())
.unwrap_or_default();
settings_config.hide_thinking_block = Some(hide_thinking_block);
settings_config.markdown.get_or_insert_default().spacing = Some(markdown_spacing);
settings_config.autocomplete_max_visible =
u32::try_from(autocomplete.max_visible.clamp(1, 20)).ok();
settings_config.double_escape_action = Some(
match double_escape_action {
DoubleEscapeAction::Rewind => "rewind",
DoubleEscapeAction::Fork => "fork",
DoubleEscapeAction::Tree => "tree",
DoubleEscapeAction::None => "none",
}
.to_string(),
);
let settings_path = settings::settings_destination(
crate::config::Config::config_path_override_from_env(&bash_cwd).as_deref(),
&crate::config::Config::global_dir(),
&bash_cwd,
session_options.workspace_trusted,
);
let (submit_tx, submit_rx) = std::sync::mpsc::channel::<UiCommand>();
let mut initial_messages_remaining = queue_initial_commands(
&submit_tx,
start_in_plan_mode,
initial_content.is_some(),
initial_messages,
)?;
let (agent_tx, agent_rx) = std::sync::mpsc::channel::<PiMsg>();
let (ask_reply_tx, ask_reply_rx) = std::sync::mpsc::channel::<AskUiReply>();
let (ext_reply_tx, ext_reply_rx) = std::sync::mpsc::channel::<ExtensionUiResponse>();
let resume_cwd = bash_cwd.display().to_string();
let bash_shell = resource_source
.as_ref()
.map(|source| BashUiShell::from_config(&source.config))
.unwrap_or_default();
let mut resume_template = resume_template_from(&session_options);
let turn_abort: TurnAbortSlot = Arc::new(Mutex::new(None));
let driver_turn_abort = Arc::clone(&turn_abort);
let turn_control: TurnControlSlot = Arc::new(Mutex::new(None));
let driver_turn_control = Arc::clone(&turn_control);
let command_registry: ExtensionCommandRegistry = Arc::default();
let driver_command_registry = Arc::clone(&command_registry);
let driver = std::thread::Builder::new()
.name("pi-ftui-agent-driver".into())
.stack_size(DRIVER_STACK_BYTES)
.spawn(move || -> std::io::Result<()> {
let runtime = match asupersync::runtime::RuntimeBuilder::new().build() {
Ok(runtime) => runtime,
Err(err) => {
let _ = agent_tx.send(PiMsg::AgentError(format!("runtime build: {err}")));
return Err(std::io::Error::other(format!(
"FTUI runtime build failed: {err}"
)));
}
};
let runtime_handle = runtime.handle();
let terminal_agent_tx = agent_tx.clone();
#[cfg_attr(windows, allow(clippy::large_futures))]
let shutdown = runtime.block_on(async move {
let (mut handle, ext_handler) = Box::pin(create_driver_session(
session_options,
&agent_tx,
ext_reply_rx,
&runtime_handle,
&driver_command_registry,
))
.await?;
let mut settings_controller =
settings::SettingsController::new(settings_config, settings_path, &handle);
resume_template.workspace = handle.workspace();
let current_ask =
install_ask_bridges(&handle, &agent_tx, ask_reply_rx, &runtime_handle);
let mut info_catalog = driver_catalog;
refresh_extension_commands(&handle, &mut info_catalog, &agent_tx);
send_conversation_reset(&handle, &agent_tx, "pi interactive stack").await;
Box::pin(send_status_snapshot(&handle, &bash_cwd, &agent_tx)).await;
if let Ok(Some(name)) = handle.with_session(crate::session::Session::get_name).await
{
let _ = agent_tx.send(PiMsg::TerminalTitle(format!("Pi · {name}")));
}
let mut auto_title = Box::new(
handle
.with_session(crate::session_title::AutoTitleController::for_session)
.await
.unwrap_or_default(),
);
let mut plans = plan_commands::PlanController::default();
let mut replacement_failure = None;
let mut login: Option<Box<DriverLogin>> = None;
loop {
let received = submit_rx.try_recv();
let mut refresh_status = received.is_ok();
match received {
Ok(UiCommand::Prompt(prompt)) => {
if initial_messages_remaining > 0 {
initial_messages_remaining -= 1;
if !publish_submitted_input(
&handle,
&agent_tx,
prompt.clone(),
prompt.clone(),
)
.await
{
continue;
}
}
run_typed_prompt(
&mut handle,
prompt,
driver_resources.as_ref(),
&bash_cwd,
auto_resize_images,
&agent_tx,
&driver_turn_control,
)
.await;
}
Ok(UiCommand::InitialPrompt) => {
run_initial_prompt(
&mut handle,
&mut initial_content,
driver_resources.as_ref(),
&agent_tx,
&driver_turn_control,
)
.await;
}
Ok(UiCommand::SetModel { provider, model }) => {
run_set_model_command(&mut handle, &provider, &model, &agent_tx).await;
}
Ok(UiCommand::Settings) => {
let _ = agent_tx.send(PiMsg::Settings {
snapshot: settings_controller.snapshot(&handle),
changed: None,
});
}
Ok(UiCommand::SetSetting { key, value }) => {
let (snapshot, changed) =
match settings_controller.apply(&mut handle, key, &value) {
Ok(snapshot) => {
auto_resize_images = snapshot.auto_resize_images;
if key == SettingsKey::AutoRetry {
resume_template.retry = handle.retry_policy();
}
if key == SettingsKey::AutoCompaction
&& let Some(compaction) =
&mut resume_template.compaction_settings
{
compaction.enabled = snapshot.auto_compaction;
}
if let Some(source) = &mut resource_source {
source.config = settings_controller.config().clone();
}
let _ = agent_tx.send(PiMsg::System(format!(
"{}: {} (saved)",
key.label(),
snapshot.value(key),
)));
(snapshot, Some(key))
}
Err(err) => {
let _ = agent_tx.send(PiMsg::AgentError(format!(
"Settings could not be saved: {err}. Live settings are unchanged.",
)));
(settings_controller.snapshot(&handle), None)
}
};
let _ = agent_tx.send(PiMsg::Settings { snapshot, changed });
}
Ok(UiCommand::Bash { command, exclude }) => {
if let Some(output) = run_bash_ui_command(
&bash_cwd,
&bash_shell,
&command,
exclude,
&agent_tx,
)
.await
&& !exclude
{
run_prompt_turn(
&mut handle,
output,
Vec::new(),
&agent_tx,
&driver_turn_control,
)
.await;
}
}
Ok(UiCommand::Compact) => {
run_compact_command(&mut handle, false, &agent_tx).await;
}
Ok(UiCommand::Shake) => {
run_compact_command(&mut handle, true, &agent_tx).await;
}
Ok(UiCommand::Plan { action }) => {
plans.run(&mut handle, &action, &agent_tx).await;
}
Ok(UiCommand::AddDir { dir }) => {
run_add_dir_command(&mut handle, &dir, &agent_tx).await;
}
Ok(UiCommand::RemoveDir { dir }) => {
run_remove_dir_command(&mut handle, &dir, &agent_tx).await;
}
Ok(UiCommand::Crash { action }) => {
run_crash_command(&action, &agent_tx);
}
Ok(UiCommand::Undo { count, force, redo }) => {
run_undo_command(&handle, count, force, redo, &agent_tx);
}
Ok(UiCommand::Usage { refresh }) => {
run_usage_command(&bash_cwd, refresh, &agent_tx).await;
}
Ok(UiCommand::Mcp { subcommand, name }) => {
run_mcp_command(&handle, &subcommand, name.as_deref(), &agent_tx).await;
}
Ok(UiCommand::ExtensionCommand { name, args }) => {
let claimed = handle
.extension_manager()
.is_some_and(|manager| manager.has_command(&name));
if template_prompt(driver_resources.as_ref(), claimed, &name, &args)
.is_some()
{
let input = if args.is_empty() {
format!("/{name}")
} else {
format!("/{name} {args}")
};
run_typed_prompt(
&mut handle,
input,
driver_resources.as_ref(),
&bash_cwd,
auto_resize_images,
&agent_tx,
&driver_turn_control,
)
.await;
} else {
run_extension_command(
&handle, &bash_cwd, &name, &args, false, &agent_tx,
)
.await;
}
}
Ok(UiCommand::RegisteredExtensionCommand { name, args }) => {
run_extension_command(&handle, &bash_cwd, &name, &args, true, &agent_tx)
.await;
}
Ok(UiCommand::ResumeSession { path }) => {
auto_title.cancel_pending();
plans.clear_review();
if let Err(err) = Box::pin(resume_session_command(
&path,
&resume_template,
&mut handle,
¤t_ask,
&ext_handler,
&agent_tx,
&runtime_handle,
))
.await
{
replacement_failure = Some(err);
break;
}
}
Ok(UiCommand::DeleteSession) => {
auto_title.cancel_pending();
let doomed = handle
.with_session(|session| session.path.clone())
.await
.ok()
.flatten()
.filter(|path| path.is_file());
let Some(doomed) = doomed else {
let _ = agent_tx.send(PiMsg::AgentError(String::from(
"No saved session file to delete (the session is new or in-memory).",
)));
continue;
};
plans.clear_review();
if let Err(err) = Box::pin(new_session_command(
&resume_template,
&mut handle,
¤t_ask,
&ext_handler,
&agent_tx,
&runtime_handle,
))
.await
{
replacement_failure = Some(err);
break;
}
let still_live = handle
.with_session(|session| session.path.clone())
.await
.map_or(true, |path| path.as_deref() == Some(doomed.as_path()));
if still_live {
let _ = agent_tx.send(PiMsg::AgentError(format!(
"Kept {}: no new session started, so it is still the live session.",
doomed.display()
)));
continue;
}
let _ = agent_tx.send(match crate::session_picker::delete_session_file(&doomed) {
Ok(()) => {
let _ = crate::session_index::SessionIndex::new()
.delete_session_path(&doomed);
PiMsg::System(format!(
"Deleted {}; this is a new session.",
doomed.display()
))
}
Err(err) => PiMsg::AgentError(format!(
"Could not delete {}: {err}",
doomed.display()
)),
});
}
Ok(UiCommand::NewSession) => {
auto_title.cancel_pending();
plans.clear_review();
if let Err(err) = Box::pin(new_session_command(
&resume_template,
&mut handle,
¤t_ask,
&ext_handler,
&agent_tx,
&runtime_handle,
))
.await
{
replacement_failure = Some(err);
break;
}
}
Ok(UiCommand::SessionInfo) => {
run_session_info_command(&handle, &agent_tx).await;
}
Ok(UiCommand::Fresh) => {
let messages = handle.session().agent.messages().len();
let _ = agent_tx.send(match handle.fresh_stream_state().await {
Ok(id) => PiMsg::System(format!(
"Fresh stream state (session id {id}); transcript untouched ({messages} messages)."
)),
Err(err) => PiMsg::AgentError(format!("fresh: {err}")),
});
}
Ok(UiCommand::Workspace { command, args }) => {
let advisor = resume_template
.advisor
.as_ref()
.map(|advisor| advisor.label.clone());
Box::pin(workspace_commands::run(
command,
&args,
&mut handle,
&bash_cwd,
advisor.as_deref(),
resource_source
.as_ref()
.map(|source| &source.package_manager),
&agent_tx,
))
.await;
}
Ok(UiCommand::Checkpoint { args }) => {
let (name, note) =
args.split_once(char::is_whitespace).unwrap_or((args.as_str(), ""));
let note = Some(note.trim()).filter(|note| !note.is_empty());
let _ = agent_tx.send(
match handle.mark_checkpoint(name.trim(), note).await {
Ok(checkpoint) => PiMsg::System(format!(
"Checkpoint '{}' marked ({} messages, ~{} tokens). Rewind with /rewind{}.",
checkpoint.name,
checkpoint.message_count,
checkpoint.token_estimate,
if checkpoint.name == "checkpoint" {
String::new()
} else {
format!(" {}", checkpoint.name)
}
)),
Err(err) => PiMsg::AgentError(format!("checkpoint: {err}")),
},
);
}
Ok(UiCommand::Rewind { name }) => {
let name = Some(name.as_str()).filter(|name| !name.is_empty());
let _ = agent_tx.send(match handle.rewind_to_checkpoint(name).await {
Ok(outcome) => PiMsg::System(format!(
"Rewound to '{}': {} messages collapsed into a report (~{} tokens). The tree kept everything.",
outcome.checkpoint,
outcome.collapsed_messages,
outcome.summary_tokens_estimate
)),
Err(err) => PiMsg::AgentError(format!("rewind: {err}")),
});
}
Ok(UiCommand::Retry) => {
run_retry_turn(&mut handle, &agent_tx, &driver_turn_control).await;
}
Ok(UiCommand::Btw {
question,
owner_session_id,
}) => {
run_btw_command(
&handle,
driver_btw_client.as_ref(),
question,
owner_session_id,
&agent_tx,
&runtime_handle,
)
.await;
}
Ok(UiCommand::Tan(work)) => {
run_tan_command(
&handle,
&bash_cwd,
subagent_role_spec.clone(),
work,
&agent_tx,
&runtime_handle,
)
.await;
}
Ok(UiCommand::Share) => {
run_share_command(
&handle,
&bash_cwd,
gh_path.clone(),
&driver_turn_abort,
&agent_tx,
)
.await;
}
Ok(UiCommand::TreeSummary) => {
run_tree_summary_command(&handle, &agent_tx).await;
}
Ok(UiCommand::Fast(request)) => {
let _ = agent_tx.send(run_fast_command(&mut handle, request));
}
Ok(UiCommand::Skillful(request)) => {
let _ = agent_tx.send(run_skillful_command(
&mut handle,
resume_template.skills_prompt.as_deref(),
request,
));
}
Ok(UiCommand::Dump) => {
let _ = agent_tx.send(run_dump_command(&mut handle));
}
Ok(UiCommand::CopyLastCommand) => {
let found = handle.with_session(last_shell_command).await;
let _ = agent_tx.send(match found {
Ok(Some(command)) => PiMsg::System(
crate::interactive::copy_text_to_clipboard(&command),
),
Ok(None) => PiMsg::AgentError(String::from("No command to copy.")),
Err(err) => PiMsg::AgentError(format!("copy: {err}")),
});
}
Ok(UiCommand::MessagePicker { fork }) => {
send_message_picker(&handle, fork, &agent_tx).await;
}
Ok(UiCommand::RewindTo { entry_id }) => {
run_rewind_command(&mut handle, &entry_id, &agent_tx).await;
}
Ok(UiCommand::SetThinking(level)) => {
run_set_thinking_command(&mut handle, level, &agent_tx).await;
}
Ok(UiCommand::CycleThinking) => {
run_cycle_thinking_command(&mut handle, &agent_tx).await;
}
Ok(UiCommand::ScopedModels { args }) => {
let _ = agent_tx.send(run_scoped_models_command(
&args,
&driver_available_models,
&mut cycle_models,
&bash_cwd,
));
}
Ok(UiCommand::CycleModel { forward }) => {
run_cycle_model_command(&mut handle, &cycle_models, forward, &agent_tx)
.await;
}
Ok(UiCommand::Export { path }) => {
run_export_command(&handle, &bash_cwd, &path, &agent_tx).await;
}
Ok(UiCommand::SetName(name)) => {
run_set_name_command(&mut handle, &name, &agent_tx).await;
}
Ok(UiCommand::Login { args }) => {
Box::pin(run_login_command(&handle, &args, &mut login, &agent_tx))
.await;
}
Ok(UiCommand::LoginSubmit(LoginInput(input))) => {
Box::pin(run_login_submit(&mut handle, &input, &mut login, &agent_tx))
.await;
}
Ok(UiCommand::Logout { args }) => {
run_logout_command(&mut handle, &args, &agent_tx);
}
Ok(UiCommand::Info(command)) => {
Box::pin(info_commands::run(
command,
&handle,
&info_catalog,
&bash_cwd,
&agent_tx,
))
.await;
}
Ok(UiCommand::Reload) => {
auto_title.cancel_pending();
if let Err(err) = Box::pin(reload_session_command(
&resume_template,
&mut handle,
¤t_ask,
&ext_handler,
&agent_tx,
&runtime_handle,
))
.await
{
replacement_failure = Some(err);
break;
}
if let Some(source) = &resource_source {
let extension_commands = handle
.extension_manager()
.map(extension_commands_for_catalog)
.unwrap_or_default();
Box::pin(reload_driver_resources(
source,
&bash_cwd,
extension_commands,
&mut driver_resources,
&mut info_catalog,
&agent_tx,
))
.await;
}
}
Ok(UiCommand::Fork { args }) => {
auto_title.cancel_pending();
if let Err(err) = Box::pin(fork_session_command(
&args,
&resume_template,
&mut handle,
¤t_ask,
&ext_handler,
&agent_tx,
&runtime_handle,
))
.await
{
replacement_failure = Some(err);
break;
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
let redirect = login
.as_ref()
.and_then(|pending| pending.1.as_ref())
.and_then(|server| server.rx.try_recv().ok());
if let Some(path) = redirect {
let url = format!("http://localhost{path}");
Box::pin(run_login_submit(
&mut handle,
&url,
&mut login,
&agent_tx,
))
.await;
continue;
}
asupersync::time::sleep(asupersync::time::wall_now(), SUBMIT_POLL)
.await;
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
}
if let Some(title) = Box::pin(auto_title.tick(
handle.session(),
title_client.as_ref(),
&runtime_handle,
))
.await
{
let _ = agent_tx.send(PiMsg::TerminalTitle(format!("Pi · {title}")));
refresh_status = true;
}
if refresh_status {
refresh_extension_commands(&handle, &mut info_catalog, &agent_tx);
Box::pin(send_status_snapshot(&handle, &bash_cwd, &agent_tx)).await;
}
}
auto_title.cancel_pending();
let session_store = handle.session_store();
let shutdown = if replacement_failure.is_some() {
handle.discard_uncommitted_resources().await
} else {
handle.shutdown_owned_resources().await
};
if driver_restart_requested.load(std::sync::atomic::Ordering::SeqCst) {
let cx = crate::agent_cx::AgentCx::for_request();
let saved = session_store
.lock(cx.cx())
.await
.ok()
.and_then(|session| session.path.clone().filter(|path| path.is_file()));
if let Ok(mut slot) = driver_restart_session.lock() {
*slot = saved;
}
}
Some((shutdown, replacement_failure))
});
let Some((shutdown, replacement_failure)) = shutdown else {
return Err(std::io::Error::other(
"FTUI agent session failed to initialize",
));
};
if let Some(replacement_failure) = replacement_failure {
return Err(terminal_replacement_error(
&terminal_agent_tx,
replacement_failure,
&shutdown,
));
}
if shutdown.completed_cleanly() {
return Ok(());
}
let issues = shutdown.failures().collect::<Vec<_>>().join("; ");
tracing::warn!(
event = "ftui.session.shutdown.incomplete",
issues,
"session-owned resources remained after exhaustive shutdown"
);
Err(std::io::Error::other(format!(
"FTUI session shutdown incomplete: {issues}"
)))
})?;
let _wt_sync_guard = std::env::var_os("WT_SESSION").map(|_| {
let mut over = ftui::core::capability_override::CapabilityOverride::new();
over.sync_output = Some(true);
ftui::core::capability_override::push_override(over)
});
let keybindings_result = KeyBindings::load_from_user_config();
if keybindings_result.has_warnings() {
tracing::warn!(
target: crate::config::USER_DIAGNOSTIC_TARGET,
"Keybindings warnings: {}",
keybindings_result.format_warnings()
);
}
let mut model = PiFtuiModel::new(agent_rx)
.with_keybindings(keybindings_result.bindings)
.with_submit_channel(submit_tx)
.with_extension_command_registry(command_registry)
.with_turn_abort(turn_abort)
.with_turn_control(turn_control)
.with_btw_client(btw_client)
.with_ask_reply_channel(ask_reply_tx)
.with_palette(FtuiPalette::from_theme(theme))
.with_available_models(available_models)
.with_model_names(model_names)
.with_restart_request(Arc::clone(&restart_requested))
.with_available_sessions(available_sessions)
.with_resume_cwd(resume_cwd)
.with_alt_screen(!inline)
.with_mouse_enabled(!disable_mouse_capture)
.with_markdown_spacing(markdown_spacing)
.with_thinking_visible(!hide_thinking_block)
.with_double_escape_action(double_escape_action)
.with_autocomplete(autocomplete)
.with_ext_reply_channel(ext_reply_tx);
if let Some(workspace) = workspace {
model.autocomplete.provider.set_workspace(workspace);
}
let app = if inline {
ftui::App::inline_auto(model, INLINE_MIN_HEIGHT, INLINE_MAX_HEIGHT)
} else {
ftui::App::fullscreen(model)
}
.with_budget(frame_budget());
let log_guard = crate::tui::TuiLogRedirectGuard::begin();
let result = if disable_mouse_capture {
app.run()
} else {
app.with_mouse().run()
};
drop(log_guard);
finish_ftui_run(result, driver.join())?;
if restart_requested.load(std::sync::atomic::Ordering::SeqCst) {
let session = restart_session.lock().ok().and_then(|mut slot| slot.take());
return restart_process(&restart_argv(
std::env::args_os().skip(1),
session.as_deref(),
));
}
Ok(())
}
fn restart_argv(
args: impl IntoIterator<Item = std::ffi::OsString>,
session: Option<&std::path::Path>,
) -> Vec<std::ffi::OsString> {
use clap::CommandFactory;
let command = crate::cli::Cli::command();
let mut valued: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut known: std::collections::HashSet<String> = std::collections::HashSet::new();
for arg in command.get_arguments() {
let takes_value = arg.get_action().takes_values();
for name in arg
.get_long_and_visible_aliases()
.into_iter()
.flatten()
.map(|long| format!("--{long}"))
.chain(
arg.get_short_and_visible_aliases()
.into_iter()
.flatten()
.map(|short| format!("-{short}")),
)
{
if takes_value {
valued.insert(name.clone());
}
known.insert(name);
}
}
let session_source = ["-c", "--continue", "-r", "--resume", "--session"];
let args: Vec<std::ffi::OsString> = args.into_iter().collect();
let mut kept = Vec::new();
let mut i = 0;
while i < args.len() {
let arg = &args[i];
let text = arg.to_string_lossy();
if text == "--" {
break;
}
if !text.starts_with('-') || text == "-" {
i += 1;
continue;
}
let (flag, inline_value) = match text.split_once('=') {
Some((flag, _)) if text.starts_with("--") => (flag.to_string(), true),
_ => (text.to_string(), false),
};
let next_is_value = args
.get(i + 1)
.is_some_and(|next| !next.to_string_lossy().starts_with('-'));
let consumes_next = !inline_value
&& if known.contains(&flag) {
valued.contains(&flag)
} else {
next_is_value
};
if !session_source.contains(&flag.as_str()) {
kept.push(arg.clone());
if consumes_next {
kept.push(args[i + 1].clone());
}
}
i += if consumes_next { 2 } else { 1 };
}
if let Some(session) = session {
kept.push("--session".into());
kept.push(session.as_os_str().to_owned());
}
kept
}
fn restart_process(args: &[std::ffi::OsString]) -> std::io::Result<()> {
use std::io::Write as _;
let exe = std::env::current_exe()?;
let _ = std::io::stdout().flush();
let mut command = std::process::Command::new(exe);
command.args(args);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
Err(command.exec())
}
#[cfg(not(unix))]
{
let status = command.status()?;
std::process::exit(status.code().unwrap_or(1));
}
}
const fn key_event_is_input(key: &ftui::KeyEvent) -> bool {
!matches!(key.kind, ftui::KeyEventKind::Release)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::StopReason;
use ftui::runtime::simulator::{CmdRecord, ProgramSimulator};
use ftui::{KeyEvent, KeyEventKind};
use std::sync::mpsc;
fn key(code: KeyCode, modifiers: Modifiers) -> Event {
Event::Key(KeyEvent {
code,
modifiers,
kind: KeyEventKind::Press,
})
}
fn new_model() -> (mpsc::Sender<PiMsg>, PiFtuiModel) {
let (tx, rx) = mpsc::channel();
(tx, PiFtuiModel::new(rx))
}
const TEST_BODY_WIDTH: u16 = 80;
#[test]
fn replacement_session_template_preserves_launch_capabilities() {
let options = crate::sdk::SessionOptions {
no_session: true,
session_path: Some(std::path::PathBuf::from("original.jsonl")),
enabled_tools: Some(vec!["read".to_string()]),
repair_policy: Some("auto-safe".to_string()),
extension_flags: vec![crate::cli::ExtensionCliFlag {
name: "verbose".to_string(),
value: Some("true".to_string()),
}],
max_tool_iterations: 17,
mcp: Some(crate::sdk::McpSessionOptions {
config_paths: vec![std::path::PathBuf::from("extra-mcp.json")],
global_dir: Some(std::path::PathBuf::from("isolated-global")),
}),
..crate::sdk::SessionOptions::default()
};
let template = resume_template_from(&options);
assert!(!template.no_session);
assert!(template.session_path.is_none());
assert_eq!(template.enabled_tools, options.enabled_tools);
assert_eq!(template.repair_policy, options.repair_policy);
assert_eq!(template.extension_flags, options.extension_flags);
assert_eq!(template.max_tool_iterations, 17);
let mcp = template.mcp.expect("MCP launch options preserved");
assert_eq!(
mcp.config_paths,
vec![std::path::PathBuf::from("extra-mcp.json")]
);
assert_eq!(
mcp.global_dir,
Some(std::path::PathBuf::from("isolated-global"))
);
assert!(
template.runtime_handle.is_none(),
"the template is built before the driver runtime exists, so it must not pretend to \
carry a handle; `replacement_options` installs the live one (bd-82331)"
);
}
#[test]
fn replacement_options_install_the_driver_runtime_and_ui_handler() {
let runtime = asupersync::runtime::RuntimeBuilder::new()
.build()
.expect("runtime for handle");
let runtime_handle = runtime.handle();
let (agent_tx, _agent_rx) = mpsc::channel::<PiMsg>();
let ext_handler = Arc::new(FtuiExtensionUiHandler::new(agent_tx));
let template = resume_template_from(&crate::sdk::SessionOptions {
no_session: true,
..crate::sdk::SessionOptions::default()
});
let options = replacement_options(&template, &ext_handler, &runtime_handle);
assert!(
options.runtime_handle.is_some(),
"a replacement session must be handed the driver runtime, or the SDK coalescer it \
builds has nothing to dispatch onto and extension observation events are silently \
dropped for the rest of the session (bd-82331)"
);
assert!(
options.extension_ui_handler.is_some(),
"a replacement session must be handed this driver's live UI handler, never an \
earlier instance"
);
assert!(
!options.no_session,
"a replacement session is always persisted, whatever the launch flags said"
);
}
#[cfg(unix)]
#[test]
#[allow(clippy::too_many_lines)]
fn standalone_launch_binds_shell_tools_and_replacements_to_the_saved_workspace() {
let _cwd_lock = crate::test_current_dir_lock();
let process_cwd = std::env::current_dir().expect("process cwd");
let root = tempfile::tempdir_in(&process_cwd).expect("relative locator fixture");
let cwd = root.path().join("attached-workspace");
let additional = root.path().join("additional-workspace");
std::fs::create_dir(&cwd).expect("attached workspace");
std::fs::create_dir(&additional).expect("additional workspace");
std::fs::write(cwd.join("probe.txt"), "attached workspace content")
.expect("workspace probe");
let path = root.path().join("conversation.jsonl");
let sessions_dir = root.path().join("future-sessions");
let mut source = crate::session::Session::in_memory();
source.header.cwd = root.path().join("moved-away").display().to_string();
source.path = Some(path.clone());
source.session_dir = Some(root.path().to_path_buf());
source.set_additional_roots(std::slice::from_ref(&additional));
crate::session_workdir::attach_session_workdir(&mut source, &cwd)
.expect("attach saved workspace");
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.with_reactor(asupersync::runtime::reactor::create_reactor().expect("reactor"))
.build()
.expect("runtime");
runtime.block_on(Box::pin(async {
source.save().await.expect("save source");
let original_bytes = std::fs::read(&path).expect("source bytes");
let options = crate::sdk::SessionOptions {
provider: Some(String::from("openai")),
model: Some(String::from("gpt-4o")),
api_key: Some(String::from("ftui-workspace-fixture-key")),
no_session: false,
session_path: Some(path.strip_prefix(&process_cwd).unwrap().to_path_buf()),
session_dir: Some(
sessions_dir
.strip_prefix(&process_cwd)
.unwrap()
.to_path_buf(),
),
enabled_tools: Some(vec![String::from("read"), String::from("write")]),
..crate::sdk::SessionOptions::default()
};
let prepared = prepare_driver_session_options(options)
.await
.expect("prepare standalone launch");
let bound = cwd.canonicalize().expect("canonical workspace");
assert_eq!(driver_bash_cwd(&prepared), bound);
assert_eq!(prepared.session_path.as_deref(), Some(path.as_path()));
assert_eq!(
prepared.session_dir.as_deref(),
Some(sessions_dir.as_path())
);
assert_eq!(
std::fs::read(&path).expect("source unchanged"),
original_bytes
);
let mut template = resume_template_from(&prepared);
let mut handle = crate::sdk::create_agent_session(prepared)
.await
.expect("start prepared session");
template.workspace = handle.workspace();
let (agent_tx, agent_rx) = mpsc::channel();
let output = run_bash_ui_command(
&bound,
&BashUiShell::default(),
"cat probe.txt && printf shell-completed > shell-result.txt",
true,
&agent_tx,
)
.await
.expect("shell command output");
assert!(output.contains("attached workspace content"), "{output}");
assert_eq!(
std::fs::read_to_string(cwd.join("shell-result.txt")).expect("shell result"),
"shell-completed"
);
assert!(
agent_rx
.try_iter()
.any(|message| matches!(message, PiMsg::BashResult { .. }))
);
let tools = handle.session().agent.shared_tools().snapshot();
let read = tools
.get("read")
.expect("read tool")
.execute(
"ftui-workspace-read",
serde_json::json!({"path": "probe.txt"}),
None,
)
.await
.expect("read through SDK workspace");
assert!(!read.is_error);
assert!(
serde_json::to_string(&read)
.unwrap()
.contains("attached workspace content")
);
let extra_file = additional.join("continued.txt");
let write = tools
.get("write")
.expect("write tool")
.execute(
"ftui-additional-write",
serde_json::json!({"path": extra_file, "content": "saved extra root"}),
None,
)
.await
.expect("write in restored additional root");
assert!(!write.is_error);
handle
.remove_workspace_root(&additional)
.await
.expect("revoke root");
assert!(
template
.workspace
.as_ref()
.unwrap()
.additional_roots()
.is_empty()
);
assert!(
tools
.get("write")
.unwrap()
.execute(
"ftui-revoked-write",
serde_json::json!({"path": extra_file, "content": "must not be written"}),
None,
)
.await
.is_err()
);
assert_eq!(
std::fs::read_to_string(extra_file).unwrap(),
"saved extra root"
);
assert_eq!(template.working_directory.as_deref(), Some(bound.as_path()));
assert_eq!(
template.session_dir.as_deref(),
Some(sessions_dir.as_path())
);
let next = crate::sdk::create_agent_session(template)
.await
.expect("create replacement in the same workspace");
assert_eq!(
next.with_session(|session| session.header.cwd.clone())
.await
.unwrap(),
bound.display().to_string()
);
assert!(next.shutdown_owned_resources().await.completed_cleanly());
assert!(handle.shutdown_owned_resources().await.completed_cleanly());
assert_eq!(
std::env::current_dir().expect("unchanged process cwd"),
process_cwd
);
}));
}
#[test]
fn standalone_launch_rejects_mismatched_or_missing_saved_workspaces() {
let _cwd_lock = crate::test_current_dir_lock();
let root = tempfile::tempdir().expect("fixture root");
let cwd = root.path().join("saved-workspace");
let other = root.path().join("other-workspace");
std::fs::create_dir(&cwd).expect("saved workspace");
std::fs::create_dir(&other).expect("other workspace");
let path = root.path().join("conversation.jsonl");
let mut source = crate::session::Session::in_memory();
source.header.cwd = cwd.display().to_string();
source.path = Some(path.clone());
source.session_dir = Some(root.path().join("sessions"));
futures::executor::block_on(async {
source.save().await.expect("save source");
let original_bytes = std::fs::read(&path).expect("source bytes");
for explicit_handle in [false, true] {
let mut options = crate::sdk::SessionOptions {
no_session: false,
session_path: Some(path.clone()),
..crate::sdk::SessionOptions::default()
};
if explicit_handle {
options.workspace = Some(crate::workspace::WorkspaceHandle::single(&other));
} else {
options.working_directory = Some(other.clone());
}
let error = prepare_driver_session_options(options)
.await
.err()
.expect("mismatch");
assert!(
error.to_string().contains("PI_SESSION_WORKDIR_MISMATCH"),
"{error}"
);
}
std::fs::rename(&cwd, root.path().join("moved-workspace")).expect("move workspace");
let error = prepare_driver_session_options(crate::sdk::SessionOptions {
no_session: false,
session_path: Some(path.clone()),
..crate::sdk::SessionOptions::default()
})
.await
.err()
.expect("missing workspace");
assert!(
error.to_string().contains("PI_SESSION_WORKDIR_UNAVAILABLE"),
"{error}"
);
assert_eq!(
std::fs::read(&path).expect("unchanged source"),
original_bytes
);
});
}
#[test]
fn standalone_no_session_ignores_an_invalid_saved_locator() {
let root = tempfile::tempdir().expect("workspace");
let path = root.path().join("invalid.jsonl");
std::fs::write(&path, "not a saved session").expect("invalid session fixture");
let options = futures::executor::block_on(prepare_driver_session_options(
crate::sdk::SessionOptions {
no_session: true,
working_directory: Some(root.path().to_path_buf()),
session_path: Some(std::path::PathBuf::from("invalid.jsonl")),
session_dir: Some(std::path::PathBuf::from("new-sessions")),
..crate::sdk::SessionOptions::default()
},
))
.expect("in-memory launch must ignore saved locator contents");
assert!(options.no_session);
assert_eq!(options.session_path.as_deref(), Some(path.as_path()));
assert_eq!(options.session_dir, Some(root.path().join("new-sessions")));
assert_eq!(driver_bash_cwd(&options), root.path());
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"not a saved session"
);
}
#[test]
fn ftui_exit_surfaces_driver_shutdown_failures_and_panics() {
let shutdown_error = finish_ftui_run(
Ok(()),
Ok(Err(std::io::Error::other("autosave was not flushed"))),
)
.expect_err("driver shutdown failure must make FTUI exit fail");
assert!(
shutdown_error
.to_string()
.contains("autosave was not flushed")
);
let panic_error = finish_ftui_run(Ok(()), Err(Box::new("driver panic")))
.expect_err("driver panic must make FTUI exit fail");
assert!(panic_error.to_string().contains("driver panicked"));
let app_error = finish_ftui_run(
Err(std::io::Error::other("terminal restore failed")),
Ok(Err(std::io::Error::other("driver shutdown failed"))),
)
.expect_err("primary app failure must be preserved");
assert!(app_error.to_string().contains("terminal restore failed"));
let app_error_before_panic = finish_ftui_run(
Err(std::io::Error::other("terminal restore failed first")),
Err(Box::new("driver panic")),
)
.expect_err("app failure must remain primary even when the driver also panics");
assert!(
app_error_before_panic
.to_string()
.contains("terminal restore failed first")
);
}
#[test]
fn terminal_replacement_failure_requests_ui_shutdown_and_aggregates_cleanup() {
let (agent_tx, agent_rx) = mpsc::channel();
let mut shutdown = crate::sdk::SessionResourceShutdown::default();
shutdown.fail(String::from("candidate extension shutdown timed out"));
let error = terminal_replacement_error(
&agent_tx,
String::from("old MCP shutdown timed out"),
&shutdown,
);
assert!(matches!(
agent_rx.recv_timeout(std::time::Duration::from_secs(1)),
Ok(PiMsg::UiShutdown)
));
let message = error.to_string();
assert!(message.contains("old MCP shutdown timed out"), "{message}");
assert!(
message.contains("candidate extension shutdown timed out"),
"{message}"
);
}
#[test]
fn streaming_deltas_accumulate_and_flush_on_done() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
assert_eq!(sim.model().state, AgentUiState::Working);
sim.send(PiFtuiMsg::Agent(PiMsg::TextDelta("hello ".into())));
sim.send(PiFtuiMsg::Agent(PiMsg::TextDelta("world".into())));
assert_eq!(sim.model().streaming, "hello world");
sim.send(PiFtuiMsg::Agent(PiMsg::AgentDone {
usage: None,
stop_reason: StopReason::Stop,
error_message: None,
}));
assert_eq!(sim.model().state, AgentUiState::Ready);
let transcript = &sim.model().transcript;
assert_eq!(transcript.len(), 1);
assert_eq!(transcript[0].text, "hello world");
assert_eq!(transcript[0].role, EntryRole::Assistant);
assert!(sim.model().streaming.is_empty());
}
#[cfg(unix)]
#[test]
fn ctrl_z_dispatches_suspend_task_and_fake_resumes() {
let (_tx, model) = new_model();
let model = model
.with_alt_screen(true)
.with_suspend_task(|| PiFtuiMsg::Resumed);
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.inject_event(key(KeyCode::Char('z'), Modifiers::CTRL));
assert!(!sim.model().suspending);
assert!(
sim.command_log()
.iter()
.any(|record| matches!(record, CmdRecord::Task))
);
}
#[test]
fn ctrl_g_replaces_the_draft_with_the_edited_text() {
let (_tx, model) = new_model();
let model = model.with_suspend_task(|| PiFtuiMsg::Edited {
text: Ok(String::from("rewritten in vim\n")),
width: 100,
height: 30,
});
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "first draft");
sim.inject_event(key(KeyCode::Char('g'), Modifiers::CTRL));
assert_eq!(sim.model().input.text(), "rewritten in vim");
assert!(!sim.model().suspending, "the repaint path unfreezes");
assert_eq!(sim.model().term, (100, 30));
assert!(
!sim.command_log()
.iter()
.any(|record| matches!(record, CmdRecord::Task)),
"ctrl+g must block the loop, not spawn a task"
);
}
#[test]
fn ctrl_g_failure_keeps_the_draft() {
let (_tx, model) = new_model();
let model = model.with_suspend_task(|| PiFtuiMsg::Edited {
text: Err(String::from("external editor: vi exited with 1")),
width: 80,
height: 24,
});
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "keep me");
sim.inject_event(key(KeyCode::Char('g'), Modifiers::CTRL));
assert_eq!(sim.model().input.text(), "keep me");
assert!(
sim.model()
.error_banner
.as_deref()
.is_some_and(|banner| banner.contains("exited with 1"))
);
}
#[test]
fn a_user_binding_on_ctrl_g_beats_the_external_editor() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keybindings.json");
std::fs::write(&path, r#"{ "deleteWordBackward": ["ctrl+g"] }"#).expect("write config");
let keybindings = KeyBindings::load(&path).expect("load keybindings");
let (_tx, model) = new_model();
let model = model
.with_keybindings(keybindings)
.with_suspend_task(|| panic!("the external editor must not open"));
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "keep drop");
sim.inject_event(key(KeyCode::Char('g'), Modifiers::CTRL));
let text = sim.model().input.text();
assert!(
text.starts_with("keep") && !text.contains("drop"),
"{text:?}"
);
}
#[cfg(unix)]
#[test]
fn run_external_editor_returns_what_the_editor_saved() {
let saved =
run_external_editor("perl -pi -e 's/draft/edited/'", "a draft\n").expect("editor ran");
assert_eq!(saved, "a edited\n");
assert!(run_external_editor("false", "a draft").is_err());
}
#[test]
fn suspend_freeze_gates_ticks_until_resize_clears_it() {
let (_tx, mut model) = new_model();
model.state = AgentUiState::Working;
model.suspending = true;
let mut sim = ProgramSimulator::new(model);
sim.init();
let before = sim.model().spinner.current_frame;
sim.send(PiFtuiMsg::Term(Event::Tick));
assert_eq!(sim.model().spinner.current_frame, before);
sim.send(PiFtuiMsg::Term(Event::Resize {
width: 100,
height: 30,
}));
assert!(!sim.model().suspending);
assert_eq!(sim.model().term, (100, 30));
let before = sim.model().spinner.current_frame;
sim.send(PiFtuiMsg::Term(Event::Tick));
assert_eq!(sim.model().spinner.current_frame, before + 1);
}
#[test]
fn resumed_message_clears_suspend_freeze() {
let (_tx, mut model) = new_model();
model.suspending = true;
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Resumed);
assert!(!sim.model().suspending);
}
#[test]
fn with_alt_screen_records_launch_mode_for_suspend() {
let (_tx, model) = new_model();
assert!(!model.alt_screen);
let model = model.with_alt_screen(true);
assert!(model.alt_screen);
}
#[test]
fn agent_text_is_sanitized_before_display() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::TextDelta(
"safe\x1b]0;pwned\x07 text".into(),
)));
let streamed = sim.model().streaming.clone();
assert!(!streamed.contains('\x1b'), "ESC survived: {streamed:?}");
assert!(!streamed.contains('\x07'), "BEL survived: {streamed:?}");
assert!(streamed.contains("safe"));
assert!(streamed.contains("text"));
}
#[test]
fn ctrl_c_clears_then_a_second_press_quits() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "half-written thought");
sim.inject_event(key(KeyCode::Char('c'), Modifiers::CTRL));
assert!(sim.is_running(), "one press must not end the session");
assert!(sim.model().input.is_empty(), "the draft is cleared");
sim.inject_event(key(KeyCode::Char('c'), Modifiers::CTRL));
assert!(!sim.is_running());
}
#[test]
fn ctrl_c_closes_an_open_picker_before_touching_the_draft() {
let (_tx, model) = new_model();
let model = model.with_available_models(vec![
String::from("openai/gpt-5"),
String::from("anthropic/claude-x"),
]);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "keep this draft");
sim.model_mut().error_banner = Some(String::from("provider said no"));
sim.inject_event(key(KeyCode::Char('l'), Modifiers::CTRL));
assert!(
sim.model().picker.is_some(),
"ctrl+l opens the model picker"
);
sim.inject_event(key(KeyCode::Char('c'), Modifiers::CTRL));
assert!(sim.is_running());
assert!(sim.model().picker.is_none(), "the picker is dismissed");
assert_eq!(sim.model().input.text(), "keep this draft");
assert_eq!(
sim.model().error_banner.as_deref(),
Some("provider said no")
);
}
#[test]
fn ctrl_c_after_the_window_does_not_quit() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.inject_event(key(KeyCode::Char('c'), Modifiers::CTRL));
sim.model_mut().last_ctrl_c = Some(
std::time::Instant::now()
.checked_sub(CTRL_C_EXIT_WINDOW * 2)
.expect("instant"),
);
sim.inject_event(key(KeyCode::Char('c'), Modifiers::CTRL));
assert!(sim.is_running());
}
fn buffer_text(buf: &ftui::Buffer, width: u16, height: u16) -> String {
let mut out = String::new();
for y in 0..height {
for x in 0..width {
let ch = buf
.get(x, y)
.and_then(|cell| cell.content.as_char())
.unwrap_or(' ');
out.push(ch);
}
out.push('\n');
}
out
}
#[test]
fn view_renders_transcript_and_status() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::System("session restored".into())));
let rendered = buffer_text(sim.capture_frame(40, 8), 40, 8);
assert!(
rendered.contains("session restored"),
"frame missing transcript line: {rendered:?}"
);
assert!(rendered.contains("pi · ready"), "frame missing header");
assert!(
rendered.contains("Type a message"),
"frame missing input placeholder: {rendered:?}"
);
}
#[test]
fn typing_and_enter_submits_to_channel_and_transcript() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
for ch in ['h', 'i'] {
sim.inject_event(key(KeyCode::Char(ch), Modifiers::empty()));
}
assert_eq!(sim.model().input.text(), "hi");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("submitted"),
UiCommand::Prompt("hi".into())
);
assert!(sim.model().input.is_empty(), "editor not cleared");
let transcript = &sim.model().transcript;
assert_eq!(transcript.len(), 1);
assert_eq!(transcript[0].text, "hi");
assert_eq!(transcript[0].role, EntryRole::User);
}
#[test]
fn alt_enter_inserts_newline_and_grows_input_region() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
assert_eq!(sim.model().input_rows(), 1);
sim.inject_event(key(KeyCode::Char('a'), Modifiers::empty()));
sim.inject_event(key(KeyCode::Enter, Modifiers::ALT));
sim.inject_event(key(KeyCode::Char('b'), Modifiers::empty()));
assert_eq!(sim.model().input.text(), "a\nb");
assert_eq!(sim.model().input_rows(), 2);
}
#[test]
fn slash_hotkeys_prints_the_key_map_from_the_users_catalog() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keybindings.json");
std::fs::write(&path, r#"{ "deleteWordBackward": ["ctrl+g"] }"#).expect("write config");
let keybindings = KeyBindings::load(&path).expect("load keybindings");
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model.with_keybindings(keybindings));
sim.init();
let before = sim.model().transcript.len();
for ch in "/hotkeys".chars() {
sim.inject_event(key(KeyCode::Char(ch), Modifiers::empty()));
}
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
let listing = sim.model().transcript[before..]
.iter()
.find(|entry| entry.role == EntryRole::System)
.map(|entry| entry.text.clone())
.expect("a System entry carrying the listing");
assert!(
listing.contains("Keyboard Shortcuts"),
"not the key map: {listing:?}"
);
let text = &listing;
assert!(
text.contains("ctrl+g"),
"the listing should reflect the user's own override: {text:?}"
);
assert!(
text.contains("Collapse/expand tool output"),
"ctrl+o is routed on FTUI now: {text:?}"
);
assert!(
text.contains("Open settings"),
"the live settings editor must be advertised on FTUI: {text:?}"
);
}
#[test]
fn slash_keys_is_an_alias_for_hotkeys() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
let before = sim.model().transcript.len();
for ch in "/keys".chars() {
sim.inject_event(key(KeyCode::Char(ch), Modifiers::empty()));
}
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(
sim.model().transcript[before..]
.iter()
.any(|entry| entry.role == EntryRole::System
&& entry.text.contains("Keyboard Shortcuts")),
"/keys should print the same listing"
);
}
#[test]
fn ctrl_l_opens_the_model_picker() {
let (_tx, model) = new_model();
let model = model.with_available_models(vec![
"anthropic/claude-sonnet-5".to_string(),
"openai/gpt-5".to_string(),
]);
let mut sim = ProgramSimulator::new(model);
sim.init();
assert!(sim.model().picker.is_none(), "picker open before the key");
sim.inject_event(key(KeyCode::Char('l'), Modifiers::CTRL));
assert!(
sim.model().picker.is_some(),
"ctrl+l should open the same picker a bare /model opens"
);
}
#[test]
fn f1_prints_the_help_entry() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
let before = sim.model().transcript.len();
sim.inject_event(key(KeyCode::F(1), Modifiers::empty()));
let added = &sim.model().transcript[before..];
assert_eq!(added.len(), 1, "f1 should add exactly one entry");
assert!(
added[0].text.contains("/model"),
"f1 should print the help entry, got {:?}",
added[0].text
);
}
#[test]
fn shift_tab_routes_the_thinking_cycle_to_the_driver() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.inject_event(key(KeyCode::Tab, Modifiers::SHIFT));
assert_eq!(
submit_rx.try_recv().expect("shift+tab routed"),
UiCommand::CycleThinking
);
assert!(
sim.model().transcript.is_empty(),
"shift+tab should report through the driver, not locally: {:?}",
sim.model().transcript
);
}
#[test]
fn status_row_shows_the_powerline_with_the_model_display_name() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::StatusSnapshot(
crate::interactive::FtuiStatusSnapshot {
model: String::from("DeepSeek V4 Pro"),
thinking: Some(String::from("high")),
mode: String::from("act"),
cwd: String::from("~/proj"),
vcs: Some(String::from("main")),
context_pct: 42,
..crate::interactive::FtuiStatusSnapshot::default()
},
)));
let rendered = buffer_text(sim.capture_frame(100, 8), 100, 8);
assert!(rendered.contains("DeepSeek V4 Pro"), "{rendered}");
assert!(!rendered.contains("deepseek/"), "{rendered}");
assert!(rendered.contains("ACT"), "{rendered}");
assert!(rendered.contains("ctx: 42%"), "{rendered}");
}
#[test]
fn context_percent_is_capped_and_zero_without_a_window() {
assert_eq!(context_percent(64_000, Some(128_000)), 50);
assert_eq!(context_percent(500_000, Some(128_000)), 100);
assert_eq!(context_percent(10, Some(0)), 0);
assert_eq!(context_percent(10, None), 0);
}
#[test]
fn status_snapshot_uses_the_catalog_name_and_last_prompt_context() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("runtime");
let dir = tempfile::tempdir().expect("tempdir");
let auth = crate::auth::AuthStorage::load(dir.path().join("auth.json")).expect("auth");
let registry = crate::models::ModelRegistry::load(&auth, None);
let entry = registry
.find("openai", "gpt-4o")
.expect("gpt-4o in catalog");
let provider = Arc::new(crate::providers::openai::OpenAIProvider::new("gpt-4o"));
let agent = crate::agent::Agent::new(
provider,
crate::tools::ToolRegistry::new(&[], std::path::Path::new("."), None),
crate::agent::AgentConfig::default(),
);
let mut stored = crate::session::Session::in_memory();
let window = u64::from(entry.model.context_window);
stored.append_message(crate::session::SessionMessage::Assistant {
message: crate::model::AssistantMessage {
content: Vec::new(),
api: String::from("openai-responses"),
provider: String::from("openai"),
model: String::from("gpt-4o"),
usage: crate::model::Usage {
input: window / 4,
cache_read: window / 4,
..crate::model::Usage::default()
},
stop_reason: crate::model::StopReason::Stop,
stop_details: None,
error_message: None,
timestamp: 0,
},
});
let session = crate::agent::AgentSession::new(
agent,
Arc::new(asupersync::sync::Mutex::new(stored)),
false,
crate::compaction::ResolvedCompactionSettings::default(),
);
let mut handle = crate::sdk::AgentSessionHandle::from_session_with_listeners(
session,
crate::sdk::EventListeners::default(),
);
handle.session_mut().set_model_registry(registry);
let (agent_tx, agent_rx) = mpsc::channel::<PiMsg>();
runtime.block_on(send_status_snapshot(&handle, dir.path(), &agent_tx));
let Ok(PiMsg::StatusSnapshot(snapshot)) = agent_rx.try_recv() else {
panic!("no status snapshot sent");
};
assert_eq!(snapshot.model, entry.model.status_label());
assert_ne!(snapshot.model, "openai/gpt-4o");
assert_eq!(snapshot.context_pct, 50);
assert_eq!(snapshot.mode, "act");
}
#[test]
fn mid_turn_steering_reads_file_references() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("notes.txt"), "the secret is 42\n").expect("write");
std::fs::write(
dir.path().join("shot.png"),
[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78,
0x9C, 0x63, 0xF8, 0xCF, 0xC0, 0xF0, 0x1F, 0x00, 0x05, 0x00, 0x01, 0xFF, 0x89, 0x99,
0x3D, 0x1D, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
],
)
.expect("write png");
let provider = Arc::new(
crate::providers::openai::OpenAIProvider::new("ftui-steer-fixture")
.with_base_url("http://127.0.0.1:1/v1"),
);
let agent = crate::agent::Agent::new(
provider,
crate::tools::ToolRegistry::new(&[], std::path::Path::new("."), None),
crate::agent::AgentConfig::default(),
);
let session = crate::agent::AgentSession::new(
agent,
Arc::new(asupersync::sync::Mutex::new(
crate::session::Session::in_memory(),
)),
false,
crate::compaction::ResolvedCompactionSettings::default(),
);
let mut handle = crate::sdk::AgentSessionHandle::from_session_with_listeners(
session,
crate::sdk::EventListeners::default(),
);
let turn = handle.prompt_controlled(String::from("start"), |_| {});
let control = turn.control();
let slot: TurnControlSlot = Arc::new(Mutex::new(Some(control.clone())));
let (_agent_tx, rx) = mpsc::channel();
let model = PiFtuiModel::new(rx)
.with_turn_control(slot)
.with_autocomplete(AutocompleteLaunch {
catalog: AutocompleteCatalog::default(),
cwd: dir.path().to_path_buf(),
max_visible: 5,
resources: None,
resource_source: None,
});
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
type_str(&mut sim, "also check @shot.png");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(sim.model().input.text(), "also check @shot.png");
assert_eq!(control.snapshot().pending_steering, 0, "nothing queued");
sim.model_mut().input.set_text("");
type_str(&mut sim, "use @notes.txt");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
let pending = control.take_pending();
assert_eq!(pending.len(), 1);
assert!(
pending[0].text.contains("the secret is 42"),
"{}",
pending[0].text
);
assert!(
!pending[0].text.contains("@notes.txt"),
"{}",
pending[0].text
);
}
#[test]
fn mid_turn_enter_steers_alt_enter_queues_and_escape_aborts() {
let provider = Arc::new(
crate::providers::openai::OpenAIProvider::new("ftui-steer-fixture")
.with_base_url("http://127.0.0.1:1/v1"),
);
let agent = crate::agent::Agent::new(
provider,
crate::tools::ToolRegistry::new(&[], std::path::Path::new("."), None),
crate::agent::AgentConfig::default(),
);
let session = crate::agent::AgentSession::new(
agent,
Arc::new(asupersync::sync::Mutex::new(
crate::session::Session::in_memory(),
)),
false,
crate::compaction::ResolvedCompactionSettings::default(),
);
let mut handle = crate::sdk::AgentSessionHandle::from_session_with_listeners(
session,
crate::sdk::EventListeners::default(),
);
let turn = handle.prompt_controlled(String::from("start"), |_| {});
let control = turn.control();
let slot: TurnControlSlot = Arc::new(Mutex::new(Some(control.clone())));
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx)
.with_submit_channel(submit_tx)
.with_turn_control(slot);
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
type_str(&mut sim, "focus on tests");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
type_str(&mut sim, "then summarize");
sim.inject_event(key(KeyCode::Enter, Modifiers::ALT));
let snapshot = control.snapshot();
assert_eq!(snapshot.pending_steering, 1, "{snapshot:?}");
assert_eq!(snapshot.pending_follow_up, 1, "{snapshot:?}");
assert!(
submit_rx.try_recv().is_err(),
"mid-turn input goes to the lane, not the command channel"
);
let texts = sim
.model()
.transcript
.iter()
.map(|entry| entry.text.clone())
.collect::<Vec<_>>();
assert!(
texts.contains(&String::from("(steer) focus on tests")),
"{texts:?}"
);
assert!(
texts.contains(&String::from("(follow-up) then summarize")),
"{texts:?}"
);
type_str(&mut sim, "/model");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(sim.model().input.text(), "/model");
assert!(submit_rx.try_recv().is_err());
assert_eq!(control.snapshot().pending_steering, 1);
sim.inject_event(key(KeyCode::Escape, Modifiers::empty()));
assert!(
!control.snapshot().accepting_input,
"Escape must abort the running turn"
);
assert!(
sim.model()
.transcript
.iter()
.any(|entry| entry.text == "Aborting..."),
"the abort is acknowledged"
);
sim.inject_event(key(KeyCode::Up, Modifiers::ALT));
assert_eq!(
sim.model().input.text(),
"focus on tests\n\nthen summarize\n\n/model"
);
let snapshot = control.snapshot();
assert_eq!(
(snapshot.pending_steering, snapshot.pending_follow_up),
(0, 0),
"{snapshot:?}"
);
drop(turn);
}
#[test]
fn ctrl_p_routes_model_cycling_to_the_driver() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.inject_event(key(KeyCode::Char('p'), Modifiers::CTRL));
assert_eq!(
submit_rx.try_recv().expect("ctrl+p routed"),
UiCommand::CycleModel { forward: true }
);
sim.inject_event(key(KeyCode::Char('p'), Modifiers::CTRL | Modifiers::SHIFT));
assert_eq!(
submit_rx.try_recv().expect("shift+ctrl+p routed"),
UiCommand::CycleModel { forward: false }
);
assert!(
sim.model().input.text().is_empty(),
"no 'p' may reach the editor"
);
}
#[test]
fn model_cycle_order_matches_the_classic_stack() {
let models = [
"openai/gpt-4o",
"anthropic/claude-a",
"google/gemini-a",
"OpenAI/GPT-4o",
]
.map(String::from);
assert_eq!(
next_cycle_model(&models, "anthropic/claude-a", true).as_deref(),
Some("google/gemini-a")
);
assert_eq!(
next_cycle_model(&models, "google/gemini-a", false).as_deref(),
Some("anthropic/claude-a")
);
assert_eq!(
next_cycle_model(&models, "anthropic/claude-a", false).as_deref(),
Some("openai/gpt-4o"),
"backward from the first wraps to the last"
);
assert_eq!(
next_cycle_model(&models, "OPENAI/gpt-4o", true).as_deref(),
Some("anthropic/claude-a"),
"the running model matches case-insensitively and forward wraps"
);
assert_eq!(
next_cycle_model(&models, "xai/grok", true).as_deref(),
Some("anthropic/claude-a")
);
assert_eq!(next_cycle_model(&[], "openai/gpt-4o", true), None);
assert_eq!(
next_cycle_model(&[String::from("openai/gpt-4o")], "OPENAI/gpt-4o", true),
None
);
}
#[test]
fn shift_tab_belongs_to_the_completion_popup_while_it_is_open() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/he");
assert!(
sim.model().completion_visible(),
"expected the completion popup for a slash prefix"
);
sim.inject_event(key(KeyCode::Tab, Modifiers::SHIFT));
assert!(
submit_rx.try_recv().is_err(),
"the popup must consume shift+tab before the thinking cycle"
);
}
#[test]
fn every_pi_command_routes_on_the_default_stack() {
let mut unrouted = Vec::new();
for command in crate::interactive::SlashCommand::ALL {
let name = command.canonical();
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, name);
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
if let Ok(UiCommand::ExtensionCommand { name: sent, .. }) = submit_rx.try_recv() {
unrouted.push(sent);
}
}
assert!(
unrouted.is_empty(),
"the current pi commands must all route on the default stack; missing {unrouted:?}"
);
}
fn settings_snapshot_fixture() -> SettingsSnapshot {
SettingsSnapshot {
path: std::path::PathBuf::from("/workspace/.pi/settings.json"),
auto_retry: true,
auto_compaction: true,
steering: crate::agent::QueueMode::OneAtATime,
follow_up: crate::agent::QueueMode::OneAtATime,
auto_resize_images: true,
hide_thinking: false,
markdown_spacing: crate::config::MarkdownSpacing::Comfortable,
double_escape: DoubleEscapeAction::Rewind,
autocomplete_rows: 8,
}
}
#[test]
fn settings_picker_round_trip_saves_only_the_selected_choice_and_keeps_the_draft() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "unfinished draft");
sim.inject_event(key(KeyCode::F(2), Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("F2 request"),
UiCommand::Settings
);
assert!(sim.model().settings_pending);
sim.send(PiFtuiMsg::Agent(PiMsg::Settings {
snapshot: settings_snapshot_fixture(),
changed: None,
}));
assert_eq!(
sim.model().picker.as_ref().expect("settings").kind,
PickerKind::Settings
);
assert_eq!(sim.model().input.text(), "unfinished draft");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
sim.model().picker.as_ref().expect("choices").kind,
PickerKind::SettingValue(SettingsKey::AutoRetry)
);
sim.inject_event(key(KeyCode::Down, Modifiers::empty()));
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("save request"),
UiCommand::SetSetting {
key: SettingsKey::AutoRetry,
value: "off".to_string(),
}
);
assert!(
sim.model()
.settings_snapshot
.as_ref()
.expect("old snapshot")
.auto_retry,
"a selection is not an acknowledged save"
);
let mut saved = settings_snapshot_fixture();
saved.auto_retry = false;
sim.send(PiFtuiMsg::Agent(PiMsg::Settings {
snapshot: saved,
changed: Some(SettingsKey::AutoRetry),
}));
assert!(
!sim.model()
.settings_snapshot
.as_ref()
.expect("saved snapshot")
.auto_retry
);
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
sim.inject_event(key(KeyCode::Escape, Modifiers::empty()));
assert_eq!(
sim.model().picker.as_ref().expect("back to settings").kind,
PickerKind::Settings
);
sim.inject_event(key(KeyCode::Escape, Modifiers::empty()));
assert!(sim.model().picker.is_none());
assert_eq!(sim.model().input.text(), "unfinished draft");
assert!(
submit_rx.try_recv().is_err(),
"dismissing settings must not submit a prompt"
);
}
#[test]
fn settings_route_obeys_extension_ownership_busy_admission_and_custom_hotkeys() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel();
let mut model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
assert!(model.route_slash_command("/settings"));
assert_eq!(
submit_rx.try_recv().expect("slash request"),
UiCommand::Settings
);
model.handle_agent(PiMsg::Settings {
snapshot: settings_snapshot_fixture(),
changed: None,
});
model.picker = None;
model.state = AgentUiState::Working;
model.request_settings();
assert!(
submit_rx.try_recv().is_err(),
"mid-turn settings must be refused"
);
model.state = AgentUiState::Ready;
model
.extension_commands
.push(crate::autocomplete::NamedEntry {
name: "settings".to_string(),
description: None,
});
assert!(model.route_slash_command("/settings extension-args"));
assert_eq!(
submit_rx.try_recv().expect("extension wins"),
UiCommand::RegisteredExtensionCommand {
name: "settings".to_string(),
args: "extension-args".to_string(),
}
);
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("keybindings.json");
std::fs::write(&path, r#"{"openSettings":["ctrl+j"]}"#).expect("custom keybinding");
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel();
let model = PiFtuiModel::new(rx)
.with_submit_channel(submit_tx)
.with_keybindings(KeyBindings::load(&path).expect("bindings"));
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.inject_event(key(KeyCode::Char('j'), Modifiers::CTRL));
assert_eq!(
submit_rx.try_recv().expect("custom settings shortcut"),
UiCommand::Settings
);
}
#[test]
fn unavailable_settings_driver_releases_the_editor_and_reports_failure() {
for disconnected in [false, true] {
let (_tx, mut model) = new_model();
if disconnected {
let (submit_tx, submit_rx) = mpsc::channel();
drop(submit_rx);
model = model.with_submit_channel(submit_tx);
}
model.input.set_text("unfinished draft");
model.request_settings();
assert!(!model.settings_pending);
assert!(model.busy.is_none());
assert_eq!(model.input.text(), "unfinished draft");
assert!(
model
.transcript
.last()
.expect("failure message")
.text
.contains("active agent session")
);
}
}
#[test]
fn settings_acknowledgement_updates_only_the_selected_live_display_preference() {
let (_tx, mut model) = new_model();
model.show_thinking = false; let mut snapshot = settings_snapshot_fixture();
snapshot.markdown_spacing = crate::config::MarkdownSpacing::Compact;
model.handle_agent(PiMsg::Settings {
snapshot: snapshot.clone(),
changed: Some(SettingsKey::MarkdownSpacing),
});
assert_eq!(
model.markdown_spacing,
crate::config::MarkdownSpacing::Compact
);
assert!(
!model.show_thinking,
"unrelated settings must preserve Ctrl+T state"
);
snapshot.autocomplete_rows = 15;
model.handle_agent(PiMsg::Settings {
snapshot: snapshot.clone(),
changed: Some(SettingsKey::AutocompleteRows),
});
assert_eq!(model.autocomplete.max_visible, 15);
snapshot.double_escape = DoubleEscapeAction::None;
model.handle_agent(PiMsg::Settings {
snapshot,
changed: Some(SettingsKey::DoubleEscape),
});
assert_eq!(model.double_escape_action, DoubleEscapeAction::None);
}
#[test]
fn slash_export_routes_with_and_without_a_path() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/export");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("bare /export routed"),
UiCommand::Export {
path: String::new()
},
"a bare /export must let the driver choose the default filename"
);
type_str(&mut sim, "/export out/report.html ");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("/export <path> routed"),
UiCommand::Export {
path: String::from("out/report.html")
},
"the argument must reach the driver trimmed and otherwise untouched"
);
}
#[test]
fn slash_changelog_prints_the_embedded_changelog_without_the_driver() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
let before = sim.model().transcript.len();
type_str(&mut sim, "/changelog");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(
submit_rx.try_recv().is_err(),
"the changelog is an embedded asset; it must not cost a driver round trip"
);
let added: Vec<(EntryRole, String)> = sim.model().transcript[before..]
.iter()
.map(|entry| (entry.role, entry.text.clone()))
.collect();
assert_eq!(added.len(), 2, "expected the echo and one reply: {added:?}");
assert_eq!(added[0].0, EntryRole::User);
assert_eq!(added[1].0, EntryRole::System);
assert!(
added[1].1.contains("# Changelog"),
"expected the embedded changelog, got {:?}",
added[1].1
);
}
#[test]
fn slash_copy_picks_a_reply_or_one_of_its_code_blocks() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let mut model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
model.push_entry(EntryRole::Assistant, String::from("first answer"));
model.push_entry(EntryRole::User, String::from("a follow-up question"));
model.push_entry(
EntryRole::Assistant,
String::from("Run this:\n```sh\ncargo test\n```\nthen\n```\nls\n```"),
);
model.push_entry(EntryRole::Assistant, String::from(" "));
let mut sim = ProgramSimulator::new(model);
sim.init();
let rows: Vec<String> = copy_choices(&sim.model().transcript)
.into_iter()
.map(|(row, _)| row)
.collect();
assert_eq!(
rows,
[
"code: ls",
"code (sh): cargo test",
"reply: Run this:",
"reply: first answer",
]
);
type_str(&mut sim, "/copy");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(sim.model().picker.is_some(), "bare /copy opens the picker");
let before = sim.model().transcript.len();
type_str(&mut sim, "first");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(
submit_rx.try_recv().is_err(),
"copying reads the transcript this stack already has"
);
let added: Vec<(EntryRole, String)> = sim.model().transcript[before..]
.iter()
.map(|entry| (entry.role, entry.text.clone()))
.collect();
let [(role, outcome)] = added.as_slice() else {
panic!("expected exactly one outcome: {added:?}");
};
assert_eq!(
*role,
EntryRole::System,
"a successful copy is not an error: {added:?}"
);
assert!(
outcome.contains("lipboard"),
"unexpected copy outcome: {outcome:?}"
);
}
#[test]
fn dump_renders_the_conversation_and_the_request_context() {
use crate::model::{
ContentBlock, Message, TextContent, ToolCall, UserContent, UserMessage,
};
let messages = vec![
Message::User(UserMessage {
content: UserContent::Text(String::from("list the files")),
timestamp: 0,
}),
Message::Assistant(Arc::new(crate::model::AssistantMessage {
content: vec![
ContentBlock::Text(TextContent::new("Looking.")),
ContentBlock::ToolCall(ToolCall {
id: String::from("t1"),
name: String::from("bash"),
arguments: serde_json::json!({"command": "ls"}),
thought_signature: None,
}),
],
..Default::default()
})),
Message::ToolResult(Arc::new(crate::model::ToolResultMessage {
tool_call_id: String::from("t1"),
tool_name: String::from("bash"),
content: vec![ContentBlock::Text(TextContent::new("no such dir"))],
details: None,
is_error: true,
timestamp: 0,
})),
];
assert_eq!(
transcript_text(&messages),
"## User\nlist the files\n\n## Assistant\nLooking.\n[tool call] bash \
{\"command\":\"ls\"}\n\n## Tool result: bash (error)\nno such dir\n\n"
);
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("runtime");
let cwd = tempfile::tempdir().expect("tempdir");
runtime.block_on(async {
let mut handle = crate::sdk::create_agent_session(crate::sdk::SessionOptions {
provider: Some(String::from("openai")),
model: Some(String::from("gpt-4o")),
api_key: Some(String::from("dummy-key")),
working_directory: Some(cwd.path().to_path_buf()),
no_session: true,
..crate::sdk::SessionOptions::default()
})
.await
.expect("create session");
assert!(matches!(
run_dump_command(&mut handle),
PiMsg::System(text) if text == "No messages to dump yet."
));
let agent = &mut handle.session_mut().agent;
agent.replace_messages(messages[..1].to_vec());
let request = agent.request_context_json();
assert!(
request["systemPrompt"]
.as_str()
.is_some_and(|s| !s.is_empty())
);
let tools = request["tools"].as_array().expect("tools");
assert!(tools.iter().any(|tool| tool["name"] == "bash"
&& tool["parameters"].is_object()
&& tool["description"].is_string()));
assert_eq!(request["messages"].as_array().map(Vec::len), Some(1));
});
let dir = tempfile::tempdir().expect("tempdir");
let path = write_private_file(dir.path(), "pi_dump_request", "{}").expect("write");
assert_eq!(std::fs::read_to_string(&path).expect("read"), "{}");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = std::fs::metadata(&path).expect("stat").permissions().mode();
assert_eq!(mode & 0o777, 0o600, "request dumps may hold secrets");
}
}
#[test]
fn slash_delete_needs_yes_before_it_routes() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let mut sim = ProgramSimulator::new(PiFtuiModel::new(rx).with_submit_channel(submit_tx));
sim.init();
type_str(&mut sim, "/delete");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(
submit_rx.try_recv().is_err(),
"bare /delete deletes nothing"
);
assert!(
sim.model()
.transcript
.last()
.is_some_and(|e| e.text.contains("/delete yes"))
);
type_str(&mut sim, "/delete YES");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(submit_rx.try_recv().ok(), Some(UiCommand::DeleteSession));
}
#[test]
fn slash_pin_toggles_the_shown_session() {
let pins = tempfile::tempdir().expect("tempdir");
let (_agent_tx, rx) = mpsc::channel();
let mut model = PiFtuiModel::new(rx).with_pins_dir(pins.path().to_path_buf());
let mut sim_without = ProgramSimulator::new(
PiFtuiModel::new(mpsc::channel().1).with_pins_dir(pins.path().to_path_buf()),
);
sim_without.init();
type_str(&mut sim_without, "/pin");
sim_without.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(
sim_without
.model()
.transcript
.last()
.is_some_and(|e| e.text == "No active session to pin.")
);
model.displayed_session_id = Some(String::from("sess-1"));
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/pin");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(session_pins::load_pinned(pins.path()).contains("sess-1"));
assert!(
sim.model()
.transcript
.last()
.is_some_and(|e| e.text.starts_with("Pinned"))
);
type_str(&mut sim, "/pin");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(session_pins::load_pinned(pins.path()).is_empty());
assert!(
sim.model()
.transcript
.last()
.is_some_and(|e| e.text == "Unpinned.")
);
}
#[test]
fn restart_argv_keeps_launch_flags_and_reopens_this_session() {
let argv = |args: &[&str]| {
args.iter()
.map(std::ffi::OsString::from)
.collect::<Vec<_>>()
};
let kept = restart_argv(
argv(&[
"--provider",
"openai",
"-c",
"--model",
"gpt-5",
"fix the bug", "--session",
"/old/session.jsonl",
"--trust",
"also positional",
"--thinking=high",
"--session=/older.jsonl",
"--extension",
"ext.js",
"--my-ext-flag", "on",
"-r",
"--",
"--literal prompt",
]),
Some(std::path::Path::new("/saved/now.jsonl")),
);
assert_eq!(
kept,
argv(&[
"--provider",
"openai",
"--model",
"gpt-5",
"--trust",
"--thinking=high",
"--extension",
"ext.js",
"--my-ext-flag",
"on",
"--session",
"/saved/now.jsonl",
])
);
assert_eq!(
restart_argv(argv(&["--no-session", "-c", "hi"]), None),
argv(&["--no-session"])
);
}
#[test]
fn slash_restart_raises_the_flag_and_quits_when_idle() {
let flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
let (_agent_tx, rx) = mpsc::channel();
let mut sim =
ProgramSimulator::new(PiFtuiModel::new(rx).with_restart_request(Arc::clone(&flag)));
sim.init();
type_str(&mut sim, "/restart");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(flag.load(std::sync::atomic::Ordering::SeqCst));
assert!(sim.model().pending_quit, "quits so run() can relaunch");
let (_agent_tx, rx) = mpsc::channel();
let mut sim = ProgramSimulator::new(PiFtuiModel::new(rx));
sim.init();
type_str(&mut sim, "/restart");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(!sim.model().pending_quit);
assert!(
sim.model()
.transcript
.last()
.is_some_and(|e| e.text == "/restart is not available here")
);
}
#[test]
fn copy_subcommands_find_the_newest_code_block_and_link() {
assert_eq!(
fenced_code_blocks("a\n```rust\nfn x() {}\n```\nb\n~~~\n```not a fence```\n~~~"),
[
(String::from("rust"), String::from("fn x() {}")),
(String::new(), String::from("```not a fence```")),
]
);
assert_eq!(
fenced_code_blocks("```py\nprint(1)"),
[(String::from("py"), String::from("print(1)"))]
);
assert!(fenced_code_blocks("no code here").is_empty());
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let mut sim = ProgramSimulator::new(PiFtuiModel::new(rx).with_submit_channel(submit_tx));
sim.init();
type_str(&mut sim, "/copy everything");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(
sim.model()
.transcript
.last()
.is_some_and(|e| e.text == "Usage: /copy [code|cmd|link]")
);
type_str(&mut sim, "/copy cmd");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(submit_rx.try_recv().ok(), Some(UiCommand::CopyLastCommand));
type_str(&mut sim, "/copy code");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(
sim.model()
.transcript
.last()
.is_some_and(|e| e.role == EntryRole::Error && e.text == "No code block to copy.")
);
}
#[test]
fn last_shell_command_is_the_newest_bash_call_or_user_run_in_full() {
let mut session = crate::session::Session::in_memory();
assert_eq!(last_shell_command(&session), None);
session.append_message(crate::session::SessionMessage::from(
crate::model::Message::Assistant(Arc::new(crate::model::AssistantMessage {
content: vec![crate::model::ContentBlock::ToolCall(
crate::model::ToolCall {
id: String::from("t1"),
name: String::from("bash"),
arguments: serde_json::json!({"command": "cargo build\ncargo test --all"}),
thought_signature: None,
},
)],
..Default::default()
})),
));
assert_eq!(
last_shell_command(&session).as_deref(),
Some("cargo build\ncargo test --all"),
"the whole multi-line command, not the card's clipped first line"
);
session.append_message(crate::session::SessionMessage::BashExecution {
command: String::from("git status"),
output: String::new(),
exit_code: 0,
cancelled: None,
truncated: None,
full_output_path: None,
timestamp: None,
extra: std::collections::HashMap::new(),
});
assert_eq!(last_shell_command(&session).as_deref(), Some("git status"));
}
#[test]
fn slash_copy_says_so_when_there_is_nothing_to_copy() {
let (_agent_tx, rx) = mpsc::channel();
let model = PiFtuiModel::new(rx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/copy");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
let last = {
let entry = sim.model().transcript.last().expect("an entry");
(entry.role, entry.text.clone())
};
assert_eq!(last.0, EntryRole::Error);
assert!(last.1.contains("No agent messages to copy yet"), "{last:?}");
}
#[test]
fn help_lists_every_command_this_stack_actually_routes() {
let help = {
let (_agent_tx, rx) = mpsc::channel();
let mut sim = ProgramSimulator::new(PiFtuiModel::new(rx));
sim.init();
type_str(&mut sim, "/help");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
sim.model()
.transcript
.last()
.expect("help entry")
.text
.clone()
};
let mut missing = Vec::new();
for command in crate::interactive::SlashCommand::ALL {
let name = command.canonical();
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, name);
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
let routed = !matches!(submit_rx.try_recv(), Ok(UiCommand::ExtensionCommand { .. }));
if routed && !help.contains(name) {
missing.push(name);
}
}
assert!(
missing.is_empty(),
"/help omits commands this stack routes: {missing:?}\n\nhelp said: {help}"
);
}
#[test]
fn a_command_pi_does_not_have_is_still_reported_as_unknown() {
let message = unrouted_command_message("definitely-not-a-pi-command", true);
assert_eq!(
message,
"Unknown command: /definitely-not-a-pi-command (try /help)"
);
let disabled = unrouted_command_message("definitely-not-a-pi-command", false);
assert!(disabled.contains("extensions disabled"), "{disabled}");
}
#[test]
fn routing_the_two_new_hotkeys_leaves_typing_alone() {
assert_eq!(
after_key("hi", KeyCode::Char('x'), Modifiers::empty()),
"hix"
);
}
fn after_key(text: &str, code: KeyCode, modifiers: Modifiers) -> String {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
for ch in text.chars() {
sim.inject_event(key(KeyCode::Char(ch), Modifiers::empty()));
}
sim.inject_event(key(code, modifiers));
sim.model().input.text()
}
#[test]
fn pis_own_default_editor_keys_reach_the_editor() {
assert_eq!(
after_key("hello world", KeyCode::Char('w'), Modifiers::CTRL),
"hello ",
"ctrl+w (DeleteWordBackward)"
);
assert_eq!(
after_key("hello world", KeyCode::Backspace, Modifiers::ALT),
"hello ",
"alt+backspace (DeleteWordBackward)"
);
assert_eq!(
after_key("hello world", KeyCode::Char('d'), Modifiers::ALT),
"hello world",
"alt+d (DeleteWordForward) at end of line has nothing ahead of it"
);
assert_eq!(
after_key("hi", KeyCode::Char('a'), Modifiers::CTRL),
"hi",
"ctrl+a (CursorLineStart) must not disturb the text"
);
assert_eq!(
after_key("hi", KeyCode::Char('b'), Modifiers::CTRL),
"hi",
"ctrl+b (CursorLeft) must not disturb the text"
);
}
#[test]
fn routed_editor_actions_edit_from_where_the_cursor_lands() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
for ch in "abc".chars() {
sim.inject_event(key(KeyCode::Char(ch), Modifiers::empty()));
}
sim.inject_event(key(KeyCode::Char('a'), Modifiers::CTRL));
sim.inject_event(key(KeyCode::Char('d'), Modifiers::CTRL));
assert_eq!(
sim.model().input.text(),
"bc",
"ctrl+a then ctrl+d should delete the first character"
);
assert_eq!(
after_key("one two", KeyCode::Char('u'), Modifiers::CTRL),
"",
"ctrl+u (DeleteToLineStart)"
);
}
#[test]
fn ctrl_u_kills_back_to_line_start_and_stops_there() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
for ch in "keep".chars() {
sim.inject_event(key(KeyCode::Char(ch), Modifiers::empty()));
}
sim.inject_event(key(KeyCode::Enter, Modifiers::ALT));
for ch in "drop".chars() {
sim.inject_event(key(KeyCode::Char(ch), Modifiers::empty()));
}
assert_eq!(sim.model().input.text(), "keep\ndrop");
sim.inject_event(key(KeyCode::Char('u'), Modifiers::CTRL));
assert_eq!(
sim.model().input.text(),
"keep\n",
"ctrl+u must clear the line it is on and leave the one above"
);
sim.inject_event(key(KeyCode::Char('u'), Modifiers::CTRL));
assert_eq!(sim.model().input.text(), "keep\n", "ctrl+u at column 0");
}
#[test]
fn a_rebound_key_from_the_users_config_works_on_this_stack() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keybindings.json");
std::fs::write(&path, r#"{ "deleteWordBackward": ["ctrl+g"] }"#).expect("write config");
let keybindings = KeyBindings::load(&path).expect("load keybindings");
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model.with_keybindings(keybindings));
sim.init();
for ch in "hello world".chars() {
sim.inject_event(key(KeyCode::Char(ch), Modifiers::empty()));
}
sim.inject_event(key(KeyCode::Char('g'), Modifiers::CTRL));
assert_eq!(
sim.model().input.text(),
"hello ",
"the rebound key did not reach the editor"
);
}
#[test]
fn the_default_catalog_still_applies_when_nothing_is_rebound() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model.with_keybindings(KeyBindings::new()));
sim.init();
for ch in "hello world".chars() {
sim.inject_event(key(KeyCode::Char(ch), Modifiers::empty()));
}
sim.inject_event(key(KeyCode::Char('w'), Modifiers::CTRL));
assert_eq!(sim.model().input.text(), "hello ");
}
#[test]
fn ctrl_u_in_the_middle_of_a_line_keeps_what_is_ahead_of_the_cursor() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
for ch in "drop keep".chars() {
sim.inject_event(key(KeyCode::Char(ch), Modifiers::empty()));
}
for _ in 0..4 {
sim.inject_event(key(KeyCode::Left, Modifiers::empty()));
}
sim.inject_event(key(KeyCode::Char('u'), Modifiers::CTRL));
assert_eq!(sim.model().input.text(), "keep");
}
#[test]
fn routing_editor_actions_leaves_the_application_keys_alone() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.inject_event(key(KeyCode::Char('d'), Modifiers::CTRL));
assert!(
sim.command_log()
.iter()
.any(|record| matches!(record, CmdRecord::Quit)),
"ctrl+d on an empty editor must still quit"
);
}
#[test]
fn ctrl_a_moves_to_line_start_rather_than_selecting_everything() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
for ch in "bcd".chars() {
sim.inject_event(key(KeyCode::Char(ch), Modifiers::empty()));
}
sim.inject_event(key(KeyCode::Char('a'), Modifiers::CTRL));
sim.inject_event(key(KeyCode::Char('a'), Modifiers::empty()));
assert_eq!(
sim.model().input.text(),
"abcd",
"ctrl+a should move to line start, not select the message"
);
}
#[test]
fn empty_submit_is_a_noop() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(sim.model().transcript.is_empty());
}
#[test]
fn editor_ignores_keys_while_agent_works() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.inject_event(key(KeyCode::Char('x'), Modifiers::empty()));
assert!(
sim.model().input.is_empty(),
"editor took input while working"
);
let rendered = buffer_text(sim.capture_frame(40, 8), 40, 8);
assert!(rendered.contains("processing"), "missing processing note");
}
#[test]
fn submitted_text_is_sanitized() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.inject_event(Event::Paste(ftui::PasteEvent::new(
"hello\x1b]0;pwned\x07world",
true,
)));
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
let UiCommand::Prompt(submitted) = submit_rx.try_recv().expect("submitted") else {
panic!("expected a prompt command");
};
assert!(!submitted.contains('\x1b'), "ESC survived: {submitted:?}");
assert!(submitted.contains("hello"));
assert!(submitted.contains("world"));
}
#[test]
fn slash_model_routes_set_model_and_bad_specs_error() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/model openai/gpt-5");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::SetModel {
provider: "openai".into(),
model: "gpt-5".into(),
}
);
type_str(&mut sim, "/model nonsense");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(submit_rx.try_recv().is_err(), "bad spec reached the driver");
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.role == EntryRole::Error && e.text == "Model not found: nonsense"),
"not-found error missing"
);
}
#[test]
fn model_selectors_resolve_like_omp() {
let available = vec![
String::from("anthropic/claude-opus-5"),
String::from("anthropic/claude-sonnet-5"),
String::from("openai/gpt-5"),
];
let resolve = |s: &str| resolve_model_selector(&available, s);
assert_eq!(
resolve("gpt-5"),
Ok((String::from("openai"), String::from("gpt-5"), None))
);
assert_eq!(
resolve("opus:high"),
Ok((
String::from("anthropic"),
String::from("claude-opus-5"),
Some(crate::model::ThinkingLevel::High)
))
);
assert!(
resolve("claude").is_err_and(|e| e.starts_with("Ambiguous") && e.contains("sonnet"))
);
assert_eq!(resolve("nope"), Err(String::from("Model not found: nope")));
assert_eq!(
resolve("ollama/llama3.2:latest"),
Ok((
String::from("ollama"),
String::from("llama3.2:latest"),
None
))
);
let bedrock = vec![String::from(
"amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0",
)];
assert_eq!(
resolve_model_selector(&bedrock, "anthropic.claude-sonnet-4-20250514-v1:0"),
Ok((
String::from("amazon-bedrock"),
String::from("anthropic.claude-sonnet-4-20250514-v1:0"),
None
))
);
assert_eq!(
resolve("amazon-bedrock/anthropic.claude-opus-4-1-20250805-v1:0"),
Ok((
String::from("amazon-bedrock"),
String::from("anthropic.claude-opus-4-1-20250805-v1:0"),
None
))
);
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let mut sim = ProgramSimulator::new(
PiFtuiModel::new(rx)
.with_submit_channel(submit_tx)
.with_available_models(available.clone()),
);
sim.init();
type_str(&mut sim, "/switch sonnet:low");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_iter().collect::<Vec<_>>(),
[
UiCommand::SetModel {
provider: "anthropic".into(),
model: "claude-sonnet-5".into(),
},
UiCommand::SetThinking(Some(crate::model::ThinkingLevel::Low)),
]
);
}
#[test]
fn slash_queue_sends_idle_and_follows_up_mid_turn() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let slot: TurnControlSlot = Arc::new(Mutex::new(None));
let mut sim = ProgramSimulator::new(
PiFtuiModel::new(rx)
.with_submit_channel(submit_tx)
.with_turn_control(slot),
);
sim.init();
type_str(&mut sim, "/queue run the tests");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().ok(),
Some(UiCommand::Prompt(String::from("run the tests")))
);
type_str(&mut sim, "/queue");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(submit_rx.try_recv().is_err());
assert!(
sim.model()
.transcript
.last()
.is_some_and(|e| e.text == "Usage: /queue <message>")
);
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
type_str(&mut sim, "/queue and then lint");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().ok(),
Some(UiCommand::Prompt(String::from("and then lint"))),
"transcript: {:?}; editor: {:?}",
sim.model()
.transcript
.iter()
.map(|e| e.text.clone())
.collect::<Vec<_>>(),
sim.model().input.text()
);
assert!(
!sim.model()
.transcript
.iter()
.any(|e| e.text.starts_with("Commands wait")),
"/queue is not refused mid-turn"
);
}
#[test]
fn non_builtin_slash_commands_route_to_extension_dispatch() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/deploy --force");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::ExtensionCommand {
name: "deploy".into(),
args: "--force".into(),
}
);
type_str(&mut sim, "/help");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(submit_rx.try_recv().is_err());
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.role == EntryRole::System && e.text.contains("/model")),
"help text missing"
);
}
#[test]
fn registered_extension_commands_override_builtins_with_original_arguments() {
for name in [
"plan", "status", "todos", "branch", "model", "memory", "review", "commit",
] {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel();
let mut sim =
ProgramSimulator::new(PiFtuiModel::new(rx).with_submit_channel(submit_tx));
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AutocompleteCatalog(
AutocompleteCatalog {
extension_commands: vec![crate::autocomplete::NamedEntry {
name: name.to_string(),
description: None,
}],
..AutocompleteCatalog::default()
},
)));
type_str(
&mut sim,
&format!("/{} Keep These ARGs", name.to_ascii_uppercase()),
);
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("registered command routed"),
UiCommand::RegisteredExtensionCommand {
name: name.to_string(),
args: String::from("Keep These ARGs"),
},
"/{name} must reach its registered handler"
);
assert!(sim.model().picker.is_none());
assert_eq!(
sim.model().busy_label(),
Some(format!("running /{name} ...").as_str())
);
assert!(submit_rx.try_recv().is_err(), "no second built-in dispatch");
}
}
#[test]
fn live_extension_registration_wins_over_stale_completion_catalogs() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel();
let manager = crate::extensions::ExtensionManager::new();
let registry = Arc::new(Mutex::new(Some(manager.clone())));
let mut model = PiFtuiModel::new(rx)
.with_submit_channel(submit_tx)
.with_extension_command_registry(Arc::clone(®istry));
manager.register_command("Plan", None);
assert!(model.route_slash_command("/PLAN unsupported-by-built-in"));
assert_eq!(
submit_rx.try_recv().expect("live registration"),
UiCommand::RegisteredExtensionCommand {
name: String::from("Plan"),
args: String::from("unsupported-by-built-in"),
}
);
let _ = model.handle_agent(PiMsg::AutocompleteCatalog(AutocompleteCatalog {
extension_commands: vec![crate::autocomplete::NamedEntry {
name: String::from("status"),
description: None,
}],
..AutocompleteCatalog::default()
}));
assert!(model.route_slash_command("/status"));
assert_eq!(
submit_rx
.try_recv()
.expect("stale completion is not authority"),
UiCommand::Info(info_commands::InfoCommand::Extensions)
);
*registry.lock().expect("registry") = None;
let _ = model.handle_agent(PiMsg::AutocompleteCatalog(AutocompleteCatalog {
extension_commands: vec![crate::autocomplete::NamedEntry {
name: String::from("Plan"),
description: None,
}],
..AutocompleteCatalog::default()
}));
assert!(model.route_slash_command("/plan status"));
assert_eq!(
submit_rx.try_recv().expect("built-in restored"),
UiCommand::Plan {
action: String::from("status")
}
);
}
#[test]
fn busy_extension_commands_keep_the_draft_and_do_not_become_steering() {
for name in ["plan", "btw", "queue"] {
for modifiers in [Modifiers::empty(), Modifiers::ALT] {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel();
let manager = crate::extensions::ExtensionManager::new();
manager.register_command(name, None);
let model = PiFtuiModel::new(rx)
.with_submit_channel(submit_tx)
.with_turn_control(Arc::default())
.with_extension_command_registry(Arc::new(Mutex::new(Some(manager))));
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
let draft = format!("/{name} Keep this draft");
type_str(&mut sim, &draft);
sim.inject_event(key(KeyCode::Enter, modifiers));
assert!(
submit_rx.try_recv().is_err(),
"busy /{name} must not dispatch"
);
assert_eq!(sim.model().input.text(), draft);
assert!(sim.model().transcript.iter().any(|entry| {
entry.role == EntryRole::Error
&& entry.text.starts_with("Extension commands wait")
}));
assert!(sim.model().input_history.is_empty());
}
}
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel();
let manager = crate::extensions::ExtensionManager::new();
manager.register_command("status", None);
let mut model = PiFtuiModel::new(rx)
.with_submit_channel(submit_tx)
.with_extension_command_registry(Arc::new(Mutex::new(Some(manager))));
model.begin_busy("reloading resources ...");
model.input.set_text("/status Keep this draft");
model.submit_input();
assert!(submit_rx.try_recv().is_err());
assert_eq!(model.input.text(), "/status Keep this draft");
assert_eq!(model.busy_label(), Some("reloading resources ..."));
let pasted = "\x1b[31m/status Keep this draft\x1b[0m";
assert!(sanitize(pasted).starts_with("/status"));
model.input.set_text(pasted);
let draft = model.input.text();
model.submit_input();
assert!(submit_rx.try_recv().is_err());
assert_eq!(model.input.text(), draft);
assert_eq!(model.busy_label(), Some("reloading resources ..."));
}
#[test]
fn tool_status_renders_while_running() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::ToolStart {
name: "bash".into(),
tool_id: "t1".into(),
}));
let rendered = buffer_text(sim.capture_frame(40, 8), 40, 8);
assert!(
rendered.contains("running bash"),
"missing tool status: {rendered:?}"
);
sim.send(PiFtuiMsg::Agent(PiMsg::ToolEnd {
name: "bash".into(),
tool_id: "t1".into(),
is_error: false,
output: None,
}));
let rendered = buffer_text(sim.capture_frame(40, 8), 40, 8);
assert!(
!rendered.contains("running bash"),
"tool status not cleared"
);
assert!(
rendered.contains("✓ bash"),
"durable tool trace missing: {rendered:?}"
);
sim.send(PiFtuiMsg::Agent(PiMsg::ToolEnd {
name: "edit".into(),
tool_id: "t2".into(),
is_error: true,
output: None,
}));
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.text.contains("✗ edit")),
"error trace missing"
);
}
#[test]
fn scroll_pins_view_and_end_resumes_tail_follow() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.inject_event(Event::Resize {
width: 30,
height: 10,
});
for i in 0..20 {
sim.send(PiFtuiMsg::Agent(PiMsg::System(format!("line-{i}"))));
}
let rendered = buffer_text(sim.capture_frame(30, 10), 30, 10);
assert!(
rendered.contains("line-19"),
"tail not followed: {rendered:?}"
);
assert!(
!rendered.contains("line-0 "),
"oldest line unexpectedly visible"
);
sim.inject_event(key(KeyCode::PageUp, Modifiers::empty()));
let rendered = buffer_text(sim.capture_frame(30, 10), 30, 10);
assert!(!rendered.contains("line-19"), "still at tail after PageUp");
assert!(
rendered.contains("lines up"),
"footer missing scroll indicator"
);
sim.send(PiFtuiMsg::Agent(PiMsg::System("line-20".into())));
let rendered = buffer_text(sim.capture_frame(30, 10), 30, 10);
assert!(
!rendered.contains("line-20"),
"pinned view was yanked to tail"
);
sim.inject_event(key(KeyCode::End, Modifiers::empty()));
let rendered = buffer_text(sim.capture_frame(30, 10), 30, 10);
assert!(
rendered.contains("line-20"),
"End did not resume tail follow"
);
}
#[test]
fn resize_reclamps_scroll_offset() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.inject_event(Event::Resize {
width: 30,
height: 10,
});
for i in 0..12 {
sim.send(PiFtuiMsg::Agent(PiMsg::System(format!("line-{i}"))));
}
sim.inject_event(key(KeyCode::PageUp, Modifiers::empty()));
assert!(sim.model().scroll_from_tail > 0);
sim.inject_event(Event::Resize {
width: 30,
height: 40,
});
assert_eq!(sim.model().scroll_from_tail, 0);
}
#[test]
fn ctrl_c_aborts_in_flight_turn_before_quit() {
let slot: TurnAbortSlot = Arc::new(Mutex::new(None));
let (abort_handle, abort_signal) = crate::agent::AbortHandle::new();
*slot.lock().expect("slot lock") = Some(abort_handle);
let (_tx, model) = new_model();
let model = model.with_turn_abort(Arc::clone(&slot));
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.inject_event(key(KeyCode::Char('c'), Modifiers::CTRL));
assert!(
!abort_signal.is_aborted(),
"the first press only clears the editor"
);
sim.inject_event(key(KeyCode::Char('c'), Modifiers::CTRL));
assert!(
abort_signal.is_aborted(),
"Ctrl-C must abort the in-flight turn"
);
assert!(
slot.lock().expect("slot lock").is_none(),
"the fired handle must be consumed"
);
}
#[test]
fn scroll_clamp_honors_rendered_total_and_survives_resize() {
let (_tx, mut model) = new_model();
for i in 0..12 {
model.push_entry(EntryRole::Assistant, format!("# Head {i}\ntext {i}"));
}
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.inject_event(Event::Resize {
width: 40,
height: 12,
});
let _ = sim.capture_frame(40, 12);
let rendered = sim.model().rendered_total_lines.get();
let approx = sim.model().conversation_line_count();
assert!(
rendered > approx,
"markdown must expand raw text (rendered={rendered}, approx={approx})"
);
for _ in 0..64 {
sim.inject_event(key(KeyCode::PageUp, Modifiers::empty()));
}
let pinned = sim.model().scroll_from_tail;
assert_eq!(pinned, sim.model().max_scroll_from_tail());
assert!(
pinned > approx.saturating_sub(sim.model().body_height()),
"scroll range still limited by the raw approximation: pinned={pinned}"
);
sim.inject_event(Event::Resize {
width: 40,
height: 11,
});
let after_resize = sim.model().scroll_from_tail;
assert!(
after_resize >= pinned.saturating_sub(2),
"resize collapsed the scroll position: before={pinned}, after={after_resize}"
);
}
#[test]
#[allow(clippy::result_large_err)]
fn drain_loop_bridges_agent_channel_until_disconnect() {
let (agent_tx, agent_rx) = mpsc::channel::<PiMsg>();
let (msg_tx, msg_rx) = mpsc::channel::<PiFtuiMsg>();
let handle = std::thread::spawn(move || {
drain_agent_events(&agent_rx, |message| msg_tx.send(message), || false);
});
agent_tx.send(PiMsg::AgentStart).unwrap();
let bridged = msg_rx
.recv_timeout(Duration::from_secs(5))
.expect("bridged message");
assert!(matches!(bridged, PiFtuiMsg::Agent(PiMsg::AgentStart)));
drop(agent_tx);
handle.join().expect("bridge thread exits cleanly");
}
#[test]
#[allow(clippy::result_large_err)]
fn drain_loop_honors_stop_predicate() {
let (agent_tx, agent_rx) = mpsc::channel::<PiMsg>();
let (msg_tx, _msg_rx) = mpsc::channel::<PiFtuiMsg>();
agent_tx.send(PiMsg::AgentStart).unwrap(); drain_agent_events(&agent_rx, |message| msg_tx.send(message), || true);
assert!(matches!(agent_rx.try_recv(), Ok(PiMsg::AgentStart)));
}
#[test]
#[allow(clippy::result_large_err)]
fn drain_loop_stops_consuming_when_the_runtime_sender_fails() {
let (agent_tx, agent_rx) = mpsc::channel::<PiMsg>();
let (msg_tx, msg_rx) = mpsc::channel::<PiFtuiMsg>();
agent_tx.send(PiMsg::AgentStart).unwrap(); agent_tx .send(PiMsg::TextDelta("still queued".to_string()))
.unwrap();
drop(agent_tx);
drop(msg_rx);
drain_agent_events(&agent_rx, |message| msg_tx.send(message), || false);
assert!(matches!(
agent_rx.try_recv(),
Ok(PiMsg::TextDelta(text)) if text == "still queued"
));
}
#[test]
fn spinner_ticks_while_working_and_stops_when_idle() {
use ftui::runtime::simulator::CmdRecord;
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
assert!(
matches!(sim.command_log().last(), Some(CmdRecord::Tick(_))),
"AgentStart did not schedule a tick: {:?}",
sim.command_log().last()
);
let frame_before = sim.model().spinner.current_frame;
sim.inject_event(Event::Tick);
assert_eq!(sim.model().spinner.current_frame, frame_before + 1);
assert!(matches!(sim.command_log().last(), Some(CmdRecord::Tick(_))));
let spin = DOTS[sim.model().spinner.current_frame % DOTS.len()];
let rendered = buffer_text(sim.capture_frame(40, 8), 40, 8);
assert!(
rendered.contains(spin),
"status missing spinner frame {spin:?}: {rendered:?}"
);
sim.send(PiFtuiMsg::Agent(PiMsg::AgentDone {
usage: None,
stop_reason: StopReason::Stop,
error_message: None,
}));
let frame_after_done = sim.model().spinner.current_frame;
sim.inject_event(Event::Tick);
assert_eq!(sim.model().spinner.current_frame, frame_after_done);
assert!(matches!(sim.command_log().last(), Some(CmdRecord::None)));
}
#[test]
fn thinking_status_then_responding_then_usage_footer() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::ThinkingDelta(
"mull it over".into(),
)));
let rendered = buffer_text(sim.capture_frame(44, 8), 44, 8);
assert!(
rendered.contains("thinking ..."),
"missing thinking: {rendered:?}"
);
sim.send(PiFtuiMsg::Agent(PiMsg::TextDelta("answer".into())));
let rendered = buffer_text(sim.capture_frame(44, 8), 44, 8);
assert!(
rendered.contains("responding ..."),
"missing responding: {rendered:?}"
);
sim.send(PiFtuiMsg::Agent(PiMsg::AgentDone {
usage: Some(crate::model::Usage {
input: 120,
output: 45,
total_tokens: 165,
..Default::default()
}),
stop_reason: StopReason::Stop,
error_message: None,
}));
let rendered = buffer_text(sim.capture_frame(44, 8), 44, 8);
assert!(
rendered.contains("tokens 120↑ 45↓ · total 165"),
"missing usage footer: {rendered:?}"
);
assert!(sim.model().thinking.is_empty(), "thinking not cleared");
}
fn ask_request(id: &str, questions: Vec<crate::ask::AskQuestion>) -> AskUiRequest {
AskUiRequest {
id: id.to_string(),
request: crate::ask::AskRequest { questions },
}
}
fn question(q: &str, options: &[&str], multi: bool) -> crate::ask::AskQuestion {
crate::ask::AskQuestion {
id: None,
question: q.to_string(),
header: None,
options: options
.iter()
.map(|label| crate::ask::AskOption {
label: (*label).to_string(),
description: None,
})
.collect(),
multi,
recommended: None,
}
}
fn type_str(sim: &mut ProgramSimulator<PiFtuiModel>, s: &str) {
for ch in s.chars() {
sim.inject_event(key(KeyCode::Char(ch), Modifiers::empty()));
}
}
#[test]
fn ask_card_collects_answers_across_questions() {
let (agent_tx, agent_rx) = mpsc::channel();
let (reply_tx, reply_rx) = mpsc::channel::<AskUiReply>();
let model = PiFtuiModel::new(agent_rx).with_ask_reply_channel(reply_tx);
drop(agent_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::AskUiRequest(ask_request(
"ask-1",
vec![
question("Pick a color?", &["red", "blue"], false),
question("Pick tools?", &["hammer", "saw"], true),
],
))));
let rendered = buffer_text(sim.capture_frame(50, 12), 50, 12);
assert!(
rendered.contains("Pick a color?"),
"card not rendered: {rendered:?}"
);
type_str(&mut sim, "2");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
let rendered = buffer_text(sim.capture_frame(50, 14), 50, 14);
assert!(
rendered.contains("Pick tools?"),
"second card missing: {rendered:?}"
);
type_str(&mut sim, "hammer, saw");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
let reply = reply_rx.try_recv().expect("ask reply sent");
assert_eq!(reply.request_id, "ask-1");
assert!(!reply.response.dismissed);
assert_eq!(reply.response.answers.len(), 2);
assert_eq!(reply.response.answers[0].selected, vec!["blue".to_string()]);
assert_eq!(
reply.response.answers[1].selected,
vec!["hammer".to_string(), "saw".to_string()]
);
assert!(sim.model().active_ask.is_none(), "ask not cleared");
}
#[test]
fn ask_cancel_dismisses() {
let (agent_tx, agent_rx) = mpsc::channel();
let (reply_tx, reply_rx) = mpsc::channel::<AskUiReply>();
let model = PiFtuiModel::new(agent_rx).with_ask_reply_channel(reply_tx);
drop(agent_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::AskUiRequest(ask_request(
"ask-2",
vec![question("Sure?", &["yes", "no"], false)],
))));
type_str(&mut sim, "cancel");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
let reply = reply_rx.try_recv().expect("dismissal sent");
assert!(reply.response.dismissed);
assert!(reply.response.answers.is_empty());
assert!(sim.model().active_ask.is_none());
}
#[test]
fn overlapping_ask_is_dismissed_without_replacing_active_card() {
let (agent_tx, agent_rx) = mpsc::channel();
let (reply_tx, reply_rx) = mpsc::channel::<AskUiReply>();
let model = PiFtuiModel::new(agent_rx).with_ask_reply_channel(reply_tx);
drop(agent_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::AskUiRequest(ask_request(
"ask-active",
vec![question("First?", &["a", "b"], false)],
))));
sim.send(PiFtuiMsg::Agent(PiMsg::AskUiRequest(ask_request(
"ask-overlap",
vec![question("Second?", &["c", "d"], false)],
))));
assert_eq!(
sim.model()
.active_ask
.as_ref()
.map(|ask| ask.request.id.as_str()),
Some("ask-active"),
"overlap must not replace the reachable card"
);
let dismissed = reply_rx.try_recv().expect("overlap receives dismissal");
assert_eq!(dismissed.request_id, "ask-overlap");
assert!(dismissed.response.dismissed);
assert!(dismissed.response.answers.is_empty());
}
#[test]
fn ask_overlapping_active_extension_is_dismissed_without_replacement() {
let (_agent_tx, rx) = mpsc::channel();
let (ask_tx, ask_rx) = mpsc::channel::<AskUiReply>();
let (ext_tx, _ext_rx) = mpsc::channel::<ExtensionUiResponse>();
let model = PiFtuiModel::new(rx)
.with_ask_reply_channel(ask_tx)
.with_ext_reply_channel(ext_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::ExtensionUiRequest(ext_request(
"ext-active",
"confirm",
serde_json::json!({"title": "First?"}),
))));
sim.send(PiFtuiMsg::Agent(PiMsg::AskUiRequest(ask_request(
"ask-overlap",
vec![question("Second?", &["a", "b"], false)],
))));
assert_eq!(
sim.model()
.active_ext
.as_ref()
.map(|request| request.id.as_str()),
Some("ext-active")
);
assert!(sim.model().active_ask.is_none());
let dismissed = ask_rx.try_recv().expect("overlap receives dismissal");
assert_eq!(dismissed.request_id, "ask-overlap");
assert!(dismissed.response.dismissed);
}
#[test]
fn ask_free_text_becomes_other_answer() {
let (agent_tx, agent_rx) = mpsc::channel();
let (reply_tx, reply_rx) = mpsc::channel::<AskUiReply>();
let model = PiFtuiModel::new(agent_rx).with_ask_reply_channel(reply_tx);
drop(agent_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::AskUiRequest(ask_request(
"ask-3",
vec![question("Which env?", &["dev", "prod"], false)],
))));
type_str(&mut sim, "staging with canary");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
let reply = reply_rx.try_recv().expect("reply sent");
assert_eq!(
reply.response.answers[0].other.as_deref(),
Some("staging with canary")
);
assert!(reply.response.answers[0].selected.is_empty());
}
#[test]
fn catalog_routes_shift_enter_newline_and_ctrl_d_exit() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.inject_event(key(KeyCode::Char('a'), Modifiers::empty()));
sim.inject_event(key(KeyCode::Enter, Modifiers::SHIFT));
sim.inject_event(key(KeyCode::Char('b'), Modifiers::empty()));
assert_eq!(sim.model().input.text(), "a\nb");
sim.inject_event(key(KeyCode::Char('d'), Modifiers::CTRL));
assert!(sim.is_running(), "ctrl+d exited despite editor content");
sim.model_mut().input.set_text("");
sim.inject_event(key(KeyCode::Char('d'), Modifiers::CTRL));
assert!(!sim.is_running(), "ctrl+d on empty editor did not exit");
}
fn ext_request(id: &str, method: &str, payload: serde_json::Value) -> ExtensionUiRequest {
ExtensionUiRequest::new(id, method, payload)
.with_extension_id(Some(String::from("demo-ext")))
}
fn send_ui_effect(
sim: &mut ProgramSimulator<PiFtuiModel>,
method: &str,
payload: serde_json::Value,
) {
sim.send(PiFtuiMsg::Agent(PiMsg::ExtensionUiRequest(ext_request(
"effect", method, payload,
))));
}
#[test]
fn set_status_reaches_the_status_line_instead_of_the_transcript() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
let before = sim.model().transcript.len();
send_ui_effect(
&mut sim,
"setStatus",
serde_json::json!({"statusKey": "working", "statusText": "indexing"}),
);
assert_eq!(
sim.model().ext_status.as_deref(),
Some("indexing"),
"ui.setWorkingMessage should set the status"
);
assert_eq!(
sim.model().transcript.len(),
before,
"an applied effect should not also print a line"
);
send_ui_effect(&mut sim, "setStatus", serde_json::json!({"statusText": ""}));
assert_eq!(sim.model().ext_status, None);
}
#[test]
fn set_editor_text_reaches_the_editor() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
let before = sim.model().transcript.len();
send_ui_effect(
&mut sim,
"set_editor_text",
serde_json::json!({"text": "drafted by an extension"}),
);
assert_eq!(sim.model().input.text(), "drafted by an extension");
assert_eq!(sim.model().transcript.len(), before);
}
fn ask_ui_query(
method: &str,
payload: serde_json::Value,
) -> (Option<serde_json::Value>, bool, usize) {
let (_agent_tx, rx) = mpsc::channel();
let (ext_tx, ext_rx) = mpsc::channel::<ExtensionUiResponse>();
let model = PiFtuiModel::new(rx).with_ext_reply_channel(ext_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
let before = sim.model().transcript.len();
sim.send(PiFtuiMsg::Agent(PiMsg::ExtensionUiRequest(ext_request(
"q", method, payload,
))));
let reply = ext_rx.try_recv().ok().and_then(|r| r.value);
(
reply,
sim.model().active_ext.is_some(),
sim.model().transcript.len() - before,
)
}
#[test]
fn get_editor_text_answers_the_extension_instead_of_prompting_the_user() {
let (reply, card, _) = ask_ui_query("getEditorText", serde_json::json!({}));
assert_eq!(reply, Some(serde_json::Value::String(String::new())));
assert!(!card, "a query must not raise a prompt card");
}
#[test]
fn theme_queries_report_what_this_stack_actually_supports() {
let (all, card, _) = ask_ui_query("getAllThemes", serde_json::json!({}));
assert!(!card);
let names: Vec<String> = all
.expect("themes")
.as_array()
.expect("array")
.iter()
.filter_map(|entry| entry["name"].as_str().map(str::to_string))
.collect();
assert_eq!(names, vec!["dark".to_string(), "light".to_string()]);
let (one, _, _) = ask_ui_query("getTheme", serde_json::json!({"name": "light"}));
assert!(one.is_some_and(|value| !value.is_null()), "light resolves");
let (missing, _, _) = ask_ui_query("getTheme", serde_json::json!({"name": "nope"}));
assert_eq!(
missing,
Some(serde_json::Value::Null),
"an unknown theme is null"
);
}
#[test]
fn set_theme_applies_and_reports_whether_it_took() {
let (applied, card, entries) =
ask_ui_query("setTheme", serde_json::json!({"name": "light"}));
assert_eq!(applied, Some(serde_json::Value::Bool(true)));
assert!(!card);
assert_eq!(entries, 1, "applying a theme announces it");
let (refused, _, _) = ask_ui_query("setTheme", serde_json::json!({"name": "nope"}));
assert_eq!(
refused,
Some(serde_json::Value::Bool(false)),
"an unsupported theme reports false rather than pretending"
);
}
#[test]
fn an_effect_this_stack_cannot_carry_out_still_prints() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
let before = sim.model().transcript.len();
send_ui_effect(
&mut sim,
"setWidget",
serde_json::json!({"widgetKey": "k", "lines": ["a"]}),
);
let added = &sim.model().transcript[before..];
assert_eq!(added.len(), 1, "setWidget should still surface somehow");
assert_eq!(added[0].role, EntryRole::System);
}
#[test]
fn extension_reply_disconnect_cancels_pending_and_rejects_new_prompts() {
let (agent_tx, agent_rx) = mpsc::channel();
let handler = FtuiExtensionUiHandler::new(agent_tx);
let (pending_tx, mut pending_rx) = asupersync::channel::oneshot::channel();
handler
.pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(String::from("startup-prompt"), pending_tx);
let (ext_reply_tx, ext_reply_rx) = mpsc::channel::<ExtensionUiResponse>();
drop(ext_reply_tx);
assert!(matches!(
poll_ext_reply(&handler, &ext_reply_rx),
ExtReplyPoll::Disconnected
));
assert!(
handler
.pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_empty(),
"disconnect must drain every outstanding prompt"
);
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("runtime");
let cancelled = runtime
.block_on(async {
let cx = crate::agent_cx::AgentCx::for_current_or_request();
pending_rx.recv(cx.cx()).await
})
.expect("pending prompt receives cancellation");
assert_eq!(cancelled.id, "startup-prompt");
assert!(cancelled.cancelled);
let mut late_request = ext_request(
"late-prompt",
"confirm",
serde_json::json!({"title": "Too late?"}),
);
late_request.timeout_ms = Some(10);
let rejected = runtime
.block_on(crate::sdk::ExtensionUiHandler::request_ui(
&handler,
late_request,
))
.expect("closed UI returns a typed response")
.expect("closed UI returns cancellation rather than absence");
assert_eq!(rejected.id, "late-prompt");
assert!(rejected.cancelled);
assert!(
agent_rx.try_recv().is_err(),
"closed reply channel must reject new prompts before UI dispatch"
);
}
#[test]
fn ftui_extension_handler_notification_never_waits_for_reply() {
asupersync::test_utils::run_test(|| async {
let (agent_tx, agent_rx) = mpsc::channel();
let handler = FtuiExtensionUiHandler::new(agent_tx);
let notification = ext_request(
"notify-1",
"notify",
serde_json::json!({"title": "Complete", "message": "Build finished"}),
);
let outcome = asupersync::time::timeout(
asupersync::time::wall_now(),
std::time::Duration::from_millis(20),
crate::sdk::ExtensionUiHandler::request_ui(&handler, notification),
)
.await
.expect("notification must not wait on the response timeout")
.expect("notification dispatch succeeds");
assert!(outcome.is_none(), "notification has no response contract");
assert!(
handler
.pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_empty(),
"notification must not allocate a pending waiter"
);
assert!(matches!(
agent_rx.try_recv(),
Ok(PiMsg::ExtensionUiRequest(request)) if request.id == "notify-1"
));
});
}
#[test]
fn dropping_ftui_extension_request_releases_pending_entry() {
asupersync::test_utils::run_test(|| async {
let (agent_tx, agent_rx) = mpsc::channel();
let handler = FtuiExtensionUiHandler::new(agent_tx);
let request = ext_request(
"cancelled-ftui-request",
"confirm",
serde_json::json!({"title": "Cancel me"}),
);
let mut attempt = Box::pin(crate::sdk::ExtensionUiHandler::request_ui(
&handler, request,
));
assert!(futures::poll!(attempt.as_mut()).is_pending());
assert!(matches!(
agent_rx.try_recv(),
Ok(PiMsg::ExtensionUiRequest(request))
if request.id == "cancelled-ftui-request"
));
assert_eq!(
handler
.pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len(),
1
);
drop(attempt);
assert!(
handler
.pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_empty(),
"cancelling the outer future must release its pending lease"
);
});
}
#[test]
fn ask_reply_disconnect_closes_picker_surface() {
let tool = crate::ask::AskTool::new(crate::ask::AskPolicy::Error);
let (ui_tx, _ui_rx) = asupersync::channel::mpsc::channel::<AskUiRequest>(4);
tool.install_channel_ui(ui_tx);
let current_ask = Arc::new(Mutex::new(Some(tool.clone())));
let (reply_tx, reply_rx) = mpsc::channel::<AskUiReply>();
drop(reply_tx);
assert!(matches!(
poll_ask_reply(¤t_ask, &reply_rx),
AskReplyPoll::Disconnected
));
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("runtime");
let result = runtime.block_on(async {
asupersync::time::timeout(
asupersync::time::wall_now(),
std::time::Duration::from_millis(20),
crate::tools::Tool::execute(
&tool,
"ask-after-disconnect",
serde_json::json!({
"questions": [{
"question": "Too late?",
"options": [{"label": "A"}, {"label": "B"}]
}]
}),
None,
),
)
.await
});
let error = result
.expect("closed picker must reject before the short mutation guard expires")
.expect_err("closed picker must reject later cards");
assert!(
error.to_string().contains("picker surface closed"),
"{error}"
);
}
#[test]
fn extension_confirm_prompt_renders_and_reply_routes() {
let (_agent_tx, rx) = mpsc::channel();
let (ext_tx, ext_rx) = mpsc::channel::<ExtensionUiResponse>();
let model = PiFtuiModel::new(rx).with_ext_reply_channel(ext_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::ExtensionUiRequest(ext_request(
"ext-1",
"confirm",
serde_json::json!({"title": "Deploy?", "message": "Ship to prod?"}),
))));
let rendered = buffer_text(sim.capture_frame(50, 12), 50, 12);
assert!(rendered.contains("Deploy?"), "prompt missing: {rendered:?}");
assert!(
rendered.contains("demo-ext"),
"provenance missing: {rendered:?}"
);
type_str(&mut sim, "yes");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
let reply = ext_rx.try_recv().expect("reply routed");
assert_eq!(reply.id, "ext-1");
assert!(!reply.cancelled);
assert_eq!(reply.value, Some(serde_json::Value::Bool(true)));
assert!(sim.model().active_ext.is_none());
}
#[test]
fn extension_prompt_escape_cancels_and_queue_advances() {
let (_agent_tx, rx) = mpsc::channel();
let (ext_tx, ext_rx) = mpsc::channel::<ExtensionUiResponse>();
let model = PiFtuiModel::new(rx).with_ext_reply_channel(ext_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::ExtensionUiRequest(ext_request(
"ext-a",
"confirm",
serde_json::json!({"title": "First?"}),
))));
sim.send(PiFtuiMsg::Agent(PiMsg::ExtensionUiRequest(ext_request(
"ext-b",
"confirm",
serde_json::json!({"title": "Second?"}),
))));
assert_eq!(sim.model().ext_queue.len(), 1, "second request not queued");
sim.inject_event(key(KeyCode::Escape, Modifiers::empty()));
let reply = ext_rx.try_recv().expect("cancel routed");
assert_eq!(reply.id, "ext-a");
assert!(reply.cancelled);
assert_eq!(
sim.model().active_ext.as_ref().map(|r| r.id.as_str()),
Some("ext-b")
);
}
#[test]
fn extension_prompt_escape_discards_partial_answer_and_restores_draft() {
let (_agent_tx, rx) = mpsc::channel();
let (ext_tx, ext_rx) = mpsc::channel::<ExtensionUiResponse>();
let model = PiFtuiModel::new(rx).with_ext_reply_channel(ext_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "saved extension draft");
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::ExtensionUiRequest(ext_request(
"ext-escape",
"input",
serde_json::json!({"title": "Value?"}),
))));
assert!(sim.model().input.text().is_empty());
type_str(&mut sim, "partial card answer");
sim.inject_event(key(KeyCode::Escape, Modifiers::empty()));
let reply = ext_rx.try_recv().expect("extension cancellation routed");
assert_eq!(reply.id, "ext-escape");
assert!(reply.cancelled);
assert!(sim.model().active_ext.is_none());
assert_eq!(sim.model().input.text(), "saved extension draft");
assert!(sim.model().card_draft_snapshot.is_none());
}
#[test]
fn extension_prompt_queues_behind_active_ask() {
let (_agent_tx, rx) = mpsc::channel();
let (ask_tx, _ask_rx) = mpsc::channel::<AskUiReply>();
let (ext_tx, _ext_rx) = mpsc::channel::<ExtensionUiResponse>();
let model = PiFtuiModel::new(rx)
.with_ask_reply_channel(ask_tx)
.with_ext_reply_channel(ext_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::AskUiRequest(ask_request(
"ask-hold",
vec![question("Pick?", &["a", "b"], false)],
))));
sim.send(PiFtuiMsg::Agent(PiMsg::ExtensionUiRequest(ext_request(
"ext-waiting",
"confirm",
serde_json::json!({"title": "Later?"}),
))));
assert!(sim.model().active_ext.is_none(), "ext jumped the ask");
assert_eq!(sim.model().ext_queue.len(), 1);
type_str(&mut sim, "1");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
sim.model().active_ext.as_ref().map(|r| r.id.as_str()),
Some("ext-waiting")
);
}
#[test]
fn mixed_card_burst_restores_only_the_preexisting_draft() {
let (_agent_tx, rx) = mpsc::channel();
let (ask_tx, _ask_rx) = mpsc::channel::<AskUiReply>();
let (ext_tx, _ext_rx) = mpsc::channel::<ExtensionUiResponse>();
let model = PiFtuiModel::new(rx)
.with_ask_reply_channel(ask_tx)
.with_ext_reply_channel(ext_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "keep this draft");
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::AskUiRequest(ask_request(
"ask-first",
vec![question("Pick?", &["a", "b"], false)],
))));
assert!(sim.model().input.text().is_empty());
sim.send(PiFtuiMsg::Agent(PiMsg::ExtensionUiRequest(ext_request(
"ext-second",
"confirm",
serde_json::json!({"title": "Continue?"}),
))));
type_str(&mut sim, "1");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
sim.model()
.active_ext
.as_ref()
.map(|request| request.id.as_str()),
Some("ext-second")
);
assert!(
sim.model().input.text().is_empty(),
"the Ask answer must not become the extension draft"
);
type_str(&mut sim, "yes");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(sim.model().input.text(), "keep this draft");
assert!(sim.model().card_draft_snapshot.is_none());
}
#[test]
fn whitespace_only_draft_is_preserved_byte_for_byte_around_card() {
let (_agent_tx, rx) = mpsc::channel();
let (ask_tx, _ask_rx) = mpsc::channel::<AskUiReply>();
let model = PiFtuiModel::new(rx).with_ask_reply_channel(ask_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.model_mut().input.set_text(" \n\t");
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::AskUiRequest(ask_request(
"ask-whitespace-draft",
vec![question("Pick?", &["a", "b"], false)],
))));
assert!(
sim.model().input.text().is_empty(),
"card activation must clear even a whitespace-only draft"
);
type_str(&mut sim, "1");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(sim.model().input.text(), " \n\t");
assert!(sim.model().card_draft_snapshot.is_none());
}
#[test]
fn extension_notification_is_nonmodal_during_active_ask() {
let (_agent_tx, rx) = mpsc::channel();
let (ask_tx, _ask_rx) = mpsc::channel::<AskUiReply>();
let (ext_tx, ext_rx) = mpsc::channel::<ExtensionUiResponse>();
let model = PiFtuiModel::new(rx)
.with_ask_reply_channel(ask_tx)
.with_ext_reply_channel(ext_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::AskUiRequest(ask_request(
"ask-hold",
vec![question("Pick?", &["a", "b"], false)],
))));
sim.send(PiFtuiMsg::Agent(PiMsg::ExtensionUiRequest(ext_request(
"notify-during-ask",
"notify",
serde_json::json!({"title": "Heads up", "message": "Build finished"}),
))));
assert_eq!(
sim.model()
.active_ask
.as_ref()
.map(|ask| ask.request.id.as_str()),
Some("ask-hold")
);
assert!(sim.model().active_ext.is_none());
assert!(sim.model().ext_queue.is_empty());
assert!(ext_rx.try_recv().is_err(), "notification has no reply");
assert!(
sim.model()
.transcript
.iter()
.any(|entry| entry.text.contains("Build finished")),
"notification must remain visible in the transcript"
);
}
fn assert_terminal_event_dismisses_ask_and_queued_extension(event: PiMsg) {
let (_agent_tx, rx) = mpsc::channel();
let (ask_tx, ask_rx) = mpsc::channel::<AskUiReply>();
let (ext_tx, ext_rx) = mpsc::channel::<ExtensionUiResponse>();
let model = PiFtuiModel::new(rx)
.with_ask_reply_channel(ask_tx)
.with_ext_reply_channel(ext_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "saved draft");
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::AskUiRequest(ask_request(
"ask-stale",
vec![question("Pick?", &["a", "b"], false)],
))));
sim.send(PiFtuiMsg::Agent(PiMsg::ExtensionUiRequest(ext_request(
"ext-stale",
"confirm",
serde_json::json!({"title": "Later?"}),
))));
type_str(&mut sim, "draft");
sim.send(PiFtuiMsg::Agent(event));
assert!(sim.model().active_ask.is_none());
assert!(sim.model().active_ext.is_none());
assert!(sim.model().ext_queue.is_empty());
assert_eq!(sim.model().input.text(), "saved draft");
assert!(sim.model().card_draft_snapshot.is_none());
let ask_reply = ask_rx.try_recv().expect("active Ask receives dismissal");
assert_eq!(ask_reply.request_id, "ask-stale");
assert!(ask_reply.response.dismissed);
let ext_reply = ext_rx
.try_recv()
.expect("queued extension prompt receives cancellation");
assert_eq!(ext_reply.id, "ext-stale");
assert!(ext_reply.cancelled);
}
#[test]
fn terminal_agent_events_invalidate_turn_owned_interactions() {
assert_terminal_event_dismisses_ask_and_queued_extension(PiMsg::AgentDone {
usage: None,
stop_reason: StopReason::Stop,
error_message: None,
});
assert_terminal_event_dismisses_ask_and_queued_extension(PiMsg::AgentError(String::from(
"turn failed",
)));
assert_terminal_event_dismisses_ask_and_queued_extension(PiMsg::ConversationReset {
session_id: String::from("replacement"),
messages: Vec::new(),
usage: crate::model::Usage::default(),
status: None,
});
}
#[test]
fn agent_done_cancels_active_and_queued_extension_prompts() {
let (_agent_tx, rx) = mpsc::channel();
let (ext_tx, ext_rx) = mpsc::channel::<ExtensionUiResponse>();
let model = PiFtuiModel::new(rx).with_ext_reply_channel(ext_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::ExtensionUiRequest(ext_request(
"ext-active",
"confirm",
serde_json::json!({"title": "Now?"}),
))));
sim.send(PiFtuiMsg::Agent(PiMsg::ExtensionUiRequest(ext_request(
"ext-queued",
"input",
serde_json::json!({"title": "Later?"}),
))));
sim.send(PiFtuiMsg::Agent(PiMsg::AgentDone {
usage: None,
stop_reason: StopReason::Stop,
error_message: None,
}));
assert!(sim.model().active_ext.is_none());
assert!(sim.model().ext_queue.is_empty());
let first = ext_rx.try_recv().expect("active prompt cancelled");
let second = ext_rx.try_recv().expect("queued prompt cancelled");
assert_eq!(first.id, "ext-active");
assert_eq!(second.id, "ext-queued");
assert!(first.cancelled && second.cancelled);
}
#[test]
fn ask_forwarder_guard_drops_requests_closed_before_dispatch() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("runtime");
let tool = crate::ask::AskTool::new(crate::ask::AskPolicy::Error);
let (agent_tx, agent_rx) = mpsc::channel();
let (ask_ui_tx, mut ask_ui_rx) = asupersync::channel::mpsc::channel::<AskUiRequest>(4);
tool.install_channel_ui(ask_ui_tx);
runtime.block_on(async {
let mut execution = Box::pin(crate::tools::Tool::execute(
&tool,
"ask-close-before-ftui-forward",
serde_json::json!({
"questions": [{
"question": "Pick?",
"options": [{"label": "A"}, {"label": "B"}]
}]
}),
None,
));
assert!(futures::poll!(execution.as_mut()).is_pending());
assert_eq!(tool.close_channel_ui(), 1);
drop(execution);
let cx = crate::agent_cx::AgentCx::for_request();
let request = ask_ui_rx
.recv(&cx)
.await
.expect("the queued ask request survives the close");
assert!(
!forward_ask_ui_request(&tool, &agent_tx, request),
"the forwarder guard must reject a request whose surface closed"
);
assert!(
agent_rx.try_recv().is_err(),
"closed Ask must not reach the FTUI model"
);
});
}
#[test]
fn escape_dismisses_active_ask() {
let (agent_tx, agent_rx) = mpsc::channel();
let (reply_tx, reply_rx) = mpsc::channel::<AskUiReply>();
let model = PiFtuiModel::new(agent_rx).with_ask_reply_channel(reply_tx);
drop(agent_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "saved draft");
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::AskUiRequest(ask_request(
"ask-esc",
vec![question("Continue?", &["yes", "no"], false)],
))));
assert!(sim.model().input.text().is_empty());
type_str(&mut sim, "partial answer");
sim.inject_event(key(KeyCode::Escape, Modifiers::empty()));
let reply = reply_rx.try_recv().expect("dismissal sent");
assert!(reply.response.dismissed);
assert!(sim.model().active_ask.is_none());
assert_eq!(sim.model().input.text(), "saved draft");
}
#[test]
fn agent_event_translation_covers_lifecycle_stream_and_tools() {
use crate::agent::AgentEvent as E;
use crate::model::{AssistantMessage, AssistantMessageEvent as A, Message, Usage};
use std::sync::Arc;
let msgs = agent_event_to_pi_msgs(&E::AgentStart {
session_id: Arc::from("s1"),
});
assert!(matches!(msgs.as_slice(), [PiMsg::AgentStart]));
let assistant = Arc::new(AssistantMessage {
usage: Usage {
input: 10,
output: 5,
total_tokens: 15,
..Default::default()
},
stop_reason: StopReason::Stop,
..Default::default()
});
let partial = Arc::clone(&assistant);
let msgs = agent_event_to_pi_msgs(&E::MessageUpdate {
message: Message::Assistant(Arc::clone(&assistant)),
assistant_message_event: A::TextDelta {
content_index: 0,
delta: "hi".into(),
partial,
},
});
assert!(matches!(msgs.as_slice(), [PiMsg::TextDelta(d)] if d == "hi"));
let msgs = agent_event_to_pi_msgs(&E::ToolExecutionStart {
tool_call_id: "t1".into(),
tool_name: "bash".into(),
args: serde_json::json!({}),
});
assert!(
matches!(msgs.as_slice(), [PiMsg::ToolStart { name, tool_id }] if name == "bash" && tool_id == "t1")
);
let msgs = agent_event_to_pi_msgs(&E::AgentEnd {
session_id: Arc::from("s1"),
messages: vec![Message::Assistant(assistant)],
error: None,
});
match msgs.as_slice() {
[
PiMsg::AgentDone {
usage: Some(usage),
stop_reason: StopReason::Stop,
error_message: None,
},
] => assert_eq!(usage.total_tokens, 15),
other => panic!("unexpected translation: {other:?}"),
}
}
#[test]
fn assistant_markdown_renders_without_markers() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::TextDelta(
"# Release Notes\n\nplain body".into(),
)));
sim.send(PiFtuiMsg::Agent(PiMsg::AgentDone {
usage: None,
stop_reason: StopReason::Stop,
error_message: None,
}));
let rendered = buffer_text(sim.capture_frame(50, 10), 50, 10);
assert!(
rendered.contains("Release Notes"),
"heading text missing: {rendered:?}"
);
assert!(
!rendered.contains("# Release Notes"),
"markdown marker leaked into frame: {rendered:?}"
);
assert!(
rendered.contains("plain body"),
"body missing: {rendered:?}"
);
}
#[test]
fn theme_picker_opens_navigates_applies_and_captures_keys() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
let dark_accent = sim.model().palette.accent;
type_str(&mut sim, "/theme");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(sim.model().picker.is_some(), "picker did not open");
let rendered = buffer_text(sim.capture_frame(50, 10), 50, 10);
assert!(
rendered.contains("Theme"),
"picker title missing: {rendered:?}"
);
assert!(rendered.contains("▸ dark"), "selection marker missing");
sim.inject_event(key(KeyCode::Char('j'), Modifiers::empty()));
assert!(sim.model().input.is_empty(), "picker leaked keys to editor");
assert_eq!(sim.model().picker.as_ref().unwrap().selected, 1);
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(sim.model().picker.is_none(), "picker did not close");
assert_ne!(
sim.model().palette.accent,
dark_accent,
"palette unchanged after applying light theme"
);
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.text.contains("theme set to light")),
"confirmation note missing"
);
}
#[test]
fn picker_respects_custom_keybindings() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keybindings.json");
std::fs::write(
&path,
r#"{
"selectDown": ["ctrl+j"],
"selectUp": ["ctrl+k"],
"selectCancel": ["q"],
"selectConfirm": ["ctrl+g"]
}"#,
)
.expect("write keybindings");
let keybindings = KeyBindings::load(&path).expect("load keybindings");
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model.with_keybindings(keybindings));
sim.init();
type_str(&mut sim, "/theme");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(sim.model().picker.is_some(), "picker did not open");
assert_eq!(sim.model().picker.as_ref().unwrap().selected, 0);
sim.inject_event(key(KeyCode::Char('j'), Modifiers::empty()));
assert_eq!(sim.model().picker.as_ref().unwrap().selected, 0);
assert_eq!(sim.model().picker.as_ref().unwrap().query, "j");
sim.inject_event(key(KeyCode::Backspace, Modifiers::empty()));
assert!(sim.model().picker.as_ref().unwrap().query.is_empty());
sim.inject_event(key(KeyCode::Down, Modifiers::empty()));
assert_eq!(sim.model().picker.as_ref().unwrap().selected, 0);
sim.inject_event(key(KeyCode::Char('j'), Modifiers::CTRL));
assert_eq!(sim.model().picker.as_ref().unwrap().selected, 1);
sim.inject_event(key(KeyCode::Up, Modifiers::empty()));
assert_eq!(sim.model().picker.as_ref().unwrap().selected, 1);
sim.inject_event(key(KeyCode::Char('k'), Modifiers::empty()));
assert_eq!(sim.model().picker.as_ref().unwrap().query, "k");
sim.inject_event(key(KeyCode::Backspace, Modifiers::empty()));
sim.inject_event(key(KeyCode::Char('j'), Modifiers::CTRL));
assert_eq!(sim.model().picker.as_ref().unwrap().selected, 1);
sim.inject_event(key(KeyCode::Char('k'), Modifiers::CTRL));
assert_eq!(sim.model().picker.as_ref().unwrap().selected, 0);
sim.inject_event(key(KeyCode::Escape, Modifiers::empty()));
assert!(
sim.model().picker.is_some(),
"picker unexpectedly closed on Escape"
);
sim.inject_event(key(KeyCode::Char('q'), Modifiers::empty()));
assert!(
sim.model().picker.is_none(),
"picker did not close on rebound key 'q'"
);
}
#[test]
fn picker_window_keeps_selection_visible() {
assert_eq!(picker_window(0, 0, 5), 0..0);
assert_eq!(picker_window(3, 10, 0), 0..0);
assert_eq!(picker_window(2, 3, 5), 0..3);
assert_eq!(picker_window(0, 20, 5), 0..5);
assert_eq!(picker_window(4, 20, 5), 0..5);
assert_eq!(picker_window(5, 20, 5), 1..6);
assert_eq!(picker_window(12, 20, 5), 8..13);
assert_eq!(picker_window(19, 20, 5), 15..20);
assert_eq!(picker_window(99, 20, 5), 15..20);
for len in 1..40 {
for visible in 1..12 {
for selected in 0..len {
let window = picker_window(selected, len, visible);
assert!(
window.contains(&selected),
"selected {selected} outside {window:?} (len {len}, visible {visible})"
);
assert_eq!(window.len(), visible.min(len));
}
}
}
}
#[test]
fn long_picker_scrolls_to_keep_the_selection_on_screen() {
let (_tx, mut model) = new_model();
model.picker = Some(PickerOverlay::new(
"Select model",
(0..40).map(|i| format!("provider/model-{i:02}")).collect(),
Vec::new(),
PickerKind::Model,
));
let mut sim = ProgramSimulator::new(model);
sim.init();
let rendered = buffer_text(sim.capture_frame(50, 10), 50, 10);
assert!(rendered.contains("▸ provider/model-00"), "{rendered:?}");
assert!(
rendered.contains("(1/40)"),
"position missing: {rendered:?}"
);
assert!(!rendered.contains("model-30"), "{rendered:?}");
for _ in 0..30 {
sim.inject_event(key(KeyCode::Char('j'), Modifiers::empty()));
}
assert_eq!(sim.model().picker.as_ref().unwrap().selected, 30);
let rendered = buffer_text(sim.capture_frame(50, 10), 50, 10);
assert!(
rendered.contains("▸ provider/model-30"),
"selection scrolled off screen: {rendered:?}"
);
assert!(
rendered.contains("(31/40)"),
"position missing: {rendered:?}"
);
assert!(
!rendered.contains("model-00"),
"top of list still drawn: {rendered:?}"
);
for _ in 0..12 {
sim.inject_event(key(KeyCode::PageDown, Modifiers::empty()));
}
assert_eq!(sim.model().picker.as_ref().unwrap().selected, 39);
let rendered = buffer_text(sim.capture_frame(50, 10), 50, 10);
assert!(rendered.contains("▸ provider/model-39"), "{rendered:?}");
assert!(rendered.contains("(40/40)"), "{rendered:?}");
for _ in 0..40 {
sim.inject_event(key(KeyCode::Char('k'), Modifiers::empty()));
}
let rendered = buffer_text(sim.capture_frame(50, 10), 50, 10);
assert!(rendered.contains("▸ provider/model-00"), "{rendered:?}");
}
#[test]
fn picker_supports_page_up_page_down() {
let (_tx, mut model) = new_model();
model.picker = Some(PickerOverlay::new(
"Long list",
(0..20).map(|i| format!("item-{i}")).collect(),
Vec::new(),
PickerKind::Theme,
));
let mut sim = ProgramSimulator::new(model);
sim.init();
assert_eq!(sim.model().picker.as_ref().unwrap().selected, 0);
sim.inject_event(key(KeyCode::PageDown, Modifiers::empty()));
let after_pagedown = sim.model().picker.as_ref().unwrap().selected;
assert!(after_pagedown > 0, "PageDown did not advance selection");
sim.inject_event(key(KeyCode::PageUp, Modifiers::empty()));
assert_eq!(sim.model().picker.as_ref().unwrap().selected, 0);
}
#[test]
fn model_picker_shows_display_names_and_routes_the_identity() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx)
.with_submit_channel(submit_tx)
.with_available_models(vec![
String::from("openai/gpt-5"),
String::from("deepseek/deepseek-v4-pro"),
])
.with_model_names(HashMap::from([
(String::from("openai/gpt-5"), String::from("openai/gpt-5")),
(
String::from("deepseek/deepseek-v4-pro"),
String::from("DeepSeek V4/Pro"),
),
]));
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/model");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
let rendered = buffer_text(sim.capture_frame(80, 10), 80, 10);
assert!(
rendered.contains("deepseek/deepseek-v4-pro · DeepSeek V4/Pro"),
"{rendered:?}"
);
assert!(!rendered.contains("gpt-5 · "), "{rendered:?}");
type_str(&mut sim, "V4/Pro");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::SetModel {
provider: "deepseek".into(),
model: "deepseek-v4-pro".into(),
}
);
}
#[test]
fn bare_model_command_opens_picker_and_selection_routes_set_model() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx)
.with_submit_channel(submit_tx)
.with_available_models(vec![
String::from("openai/gpt-5"),
String::from("anthropic/claude-opus-5"),
]);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/model");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(sim.model().picker.is_some(), "picker did not open");
let rendered = buffer_text(sim.capture_frame(50, 10), 50, 10);
assert!(
rendered.contains("▸ openai/gpt-5"),
"first entry not selected: {rendered:?}"
);
sim.inject_event(key(KeyCode::Down, Modifiers::empty()));
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::SetModel {
provider: "anthropic".into(),
model: "claude-opus-5".into(),
}
);
assert!(sim.model().picker.is_none());
}
fn filter_test_model(
models: &[&str],
) -> (ProgramSimulator<PiFtuiModel>, mpsc::Receiver<UiCommand>) {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx)
.with_submit_channel(submit_tx)
.with_available_models(models.iter().map(|m| (*m).to_string()).collect());
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/model");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(sim.model().picker.is_some(), "picker did not open");
(sim, submit_rx)
}
const FILTER_MODELS: [&str; 4] = [
"openai/gpt-5",
"anthropic/claude-opus-5",
"google/gemini-3-pro",
"xai/grok-4",
];
#[test]
fn typing_filters_the_model_picker() {
let (mut sim, submit_rx) = filter_test_model(&FILTER_MODELS);
type_str(&mut sim, "claude");
let rendered = buffer_text(sim.capture_frame(80, 10), 80, 10);
assert!(
rendered.contains("▸ anthropic/claude-opus-5"),
"match not selected: {rendered:?}"
);
assert!(rendered.contains("filter: claude (1/4)"), "{rendered:?}");
assert!(!rendered.contains("openai/gpt-5"), "{rendered:?}");
assert!(!rendered.contains("google/gemini"), "{rendered:?}");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::SetModel {
provider: "anthropic".into(),
model: "claude-opus-5".into(),
}
);
assert!(sim.model().picker.is_none());
}
#[test]
fn model_picker_filter_takes_j_k_and_backspace() {
let (mut sim, _submit_rx) = filter_test_model(&FILTER_MODELS);
sim.inject_event(key(KeyCode::Char('j'), Modifiers::empty()));
assert_eq!(sim.model().picker.as_ref().unwrap().selected, 1);
assert!(sim.model().picker.as_ref().unwrap().query.is_empty());
type_str(&mut sim, "g");
let picker = sim.model().picker.as_ref().unwrap();
assert_eq!(picker.selected, 0, "filtering resets the selection");
assert_eq!(picker.shown, vec![0, 2, 3]);
sim.inject_event(key(KeyCode::Char('k'), Modifiers::empty()));
let picker = sim.model().picker.as_ref().unwrap();
assert_eq!(picker.query, "gk");
assert_eq!(picker.shown, vec![3]);
sim.inject_event(key(KeyCode::Backspace, Modifiers::empty()));
let picker = sim.model().picker.as_ref().unwrap();
assert_eq!(picker.query, "g");
assert_eq!(picker.shown, vec![0, 2, 3]);
sim.inject_event(key(KeyCode::Down, Modifiers::empty()));
sim.inject_event(key(KeyCode::Down, Modifiers::empty()));
sim.inject_event(key(KeyCode::Down, Modifiers::empty()));
assert_eq!(
sim.model().picker.as_ref().unwrap().selected,
2,
"Down stops at the last match"
);
}
#[test]
fn model_picker_with_no_matches_keeps_enter_inert() {
let (mut sim, submit_rx) = filter_test_model(&FILTER_MODELS);
type_str(&mut sim, "zzz");
let rendered = buffer_text(sim.capture_frame(80, 10), 80, 10);
assert!(rendered.contains("no matches"), "{rendered:?}");
assert!(rendered.contains("filter: zzz (0/4)"), "{rendered:?}");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(sim.model().picker.is_some(), "Enter closed an empty picker");
assert!(submit_rx.try_recv().is_err(), "Enter routed a command");
sim.inject_event(key(KeyCode::Escape, Modifiers::empty()));
assert!(sim.model().picker.is_none());
}
#[test]
fn resume_picker_filters_on_labels() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx)
.with_submit_channel(submit_tx)
.with_available_sessions(vec![
(
String::from("fix parser · 12 msgs"),
String::from("/tmp/sessions/a.jsonl"),
),
(
String::from("Older run · 3 msgs"),
String::from("/tmp/sessions/b.jsonl"),
),
]);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/resume");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
sim.inject_event(key(KeyCode::Char('O'), Modifiers::SHIFT));
type_str(&mut sim, "ld");
let rendered = buffer_text(sim.capture_frame(80, 10), 80, 10);
assert!(rendered.contains("▸ Older run"), "{rendered:?}");
assert!(!rendered.contains("fix parser"), "{rendered:?}");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::ResumeSession {
path: "/tmp/sessions/b.jsonl".into()
}
);
}
#[test]
fn paste_into_open_picker_edits_the_filter() {
let (mut sim, _submit_rx) = filter_test_model(&FILTER_MODELS);
sim.inject_event(Event::Paste(ftui::PasteEvent::new("claude\r\n", true)));
let picker = sim.model().picker.as_ref().expect("picker stays open");
assert_eq!(picker.query, "claude");
assert_eq!(picker.shown, vec![1]);
assert!(
sim.model().input.is_empty(),
"paste leaked into the editor: {:?}",
sim.model().input.text()
);
sim.inject_event(Event::Paste(ftui::PasteEvent::new("\n", true)));
assert_eq!(sim.model().picker.as_ref().unwrap().query, "claude");
}
#[cfg(unix)]
#[test]
fn skillful_takes_the_skills_block_out_and_puts_it_back() {
let block = "\n\n<available_skills>skillful-marker</available_skills>";
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("runtime");
let cwd = tempfile::tempdir().expect("tempdir");
runtime.block_on(async {
let mut handle = crate::sdk::create_agent_session(crate::sdk::SessionOptions {
provider: Some(String::from("openai")),
model: Some(String::from("gpt-4o")),
api_key: Some(String::from("dummy-key")),
working_directory: Some(cwd.path().to_path_buf()),
no_session: true,
skills_prompt: Some(block.to_string()),
..crate::sdk::SessionOptions::default()
})
.await
.expect("create session");
let listed = |handle: &crate::sdk::AgentSessionHandle| {
handle
.session()
.agent
.system_prompt()
.is_some_and(|p| p.contains("skillful-marker"))
};
let text = |msg: PiMsg| match msg {
PiMsg::System(text) => text,
other => panic!("unexpected {other:?}"),
};
assert!(listed(&handle));
let before = handle.session().agent.system_prompt().map(str::to_string);
assert_eq!(
text(run_skillful_command(
&mut handle,
Some(block),
ToggleRequest::Off
)),
"Skill listing disabled for this session."
);
assert!(!listed(&handle));
assert!(
text(run_skillful_command(
&mut handle,
Some(block),
ToggleRequest::Status
))
.contains("off")
);
run_skillful_command(&mut handle, Some(block), ToggleRequest::Toggle);
assert!(listed(&handle));
let after = handle.session().agent.system_prompt().map(str::to_string);
assert_eq!(
before.map(|p| p.replacen(block, "", 1)),
after.map(|p| p.replacen(block, "", 1))
);
assert!(
text(run_skillful_command(&mut handle, None, ToggleRequest::Off))
.starts_with("No skills are loaded")
);
assert!(listed(&handle));
});
assert_eq!(ToggleRequest::parse(" ON "), Some(ToggleRequest::On));
assert_eq!(ToggleRequest::parse(""), Some(ToggleRequest::Toggle));
assert_eq!(ToggleRequest::parse("maybe"), None);
}
#[test]
fn fast_command_sets_the_priority_tier_the_provider_sends() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("runtime");
let cwd = tempfile::tempdir().expect("tempdir");
runtime.block_on(async {
let mut handle = crate::sdk::create_agent_session(crate::sdk::SessionOptions {
provider: Some(String::from("openai")),
model: Some(String::from("gpt-4o")),
api_key: Some(String::from("dummy-key")),
working_directory: Some(cwd.path().to_path_buf()),
no_session: true,
..crate::sdk::SessionOptions::default()
})
.await
.expect("create session");
let tier = |handle: &crate::sdk::AgentSessionHandle| {
handle.session().agent.stream_options().service_tier.clone()
};
let text = |msg: PiMsg| match msg {
PiMsg::System(text) => text,
other => panic!("unexpected reply {other:?}"),
};
assert_eq!(
text(run_fast_command(&mut handle, ToggleRequest::Status)),
"Fast mode is off."
);
assert!(
text(run_fast_command(&mut handle, ToggleRequest::On))
.starts_with("Fast mode enabled")
);
assert_eq!(tier(&handle).as_deref(), Some("priority"));
assert!(
text(run_fast_command(&mut handle, ToggleRequest::Status))
.contains("service_tier=priority")
);
assert_eq!(
text(run_fast_command(&mut handle, ToggleRequest::Toggle)),
"Fast mode disabled."
);
assert_eq!(tier(&handle), None);
run_fast_command(&mut handle, ToggleRequest::Toggle);
assert_eq!(tier(&handle).as_deref(), Some("priority"));
run_fast_command(&mut handle, ToggleRequest::Off);
assert_eq!(tier(&handle), None);
});
assert!(fast_mode_realization("groq").is_none());
assert!(fast_mode_realization("anthropic").is_some());
}
#[test]
fn double_escape_on_an_idle_empty_editor_runs_the_configured_action() {
let esc_esc = |action: DoubleEscapeAction, typed: &str| {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx)
.with_submit_channel(submit_tx)
.with_double_escape_action(action);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, typed);
sim.inject_event(key(KeyCode::Escape, Modifiers::empty()));
let after_one = submit_rx.try_recv().ok();
sim.inject_event(key(KeyCode::Escape, Modifiers::empty()));
(after_one, submit_rx.try_recv().ok())
};
assert_eq!(
esc_esc(DoubleEscapeAction::Rewind, ""),
(None, Some(UiCommand::MessagePicker { fork: false }))
);
assert_eq!(
esc_esc(DoubleEscapeAction::Fork, ""),
(None, Some(UiCommand::MessagePicker { fork: true }))
);
assert_eq!(
esc_esc(DoubleEscapeAction::Tree, ""),
(None, Some(UiCommand::TreeSummary))
);
assert_eq!(esc_esc(DoubleEscapeAction::None, ""), (None, None));
assert_eq!(esc_esc(DoubleEscapeAction::Rewind, "draft"), (None, None));
assert_eq!(
DoubleEscapeAction::from_setting(None),
DoubleEscapeAction::Rewind
);
assert_eq!(
DoubleEscapeAction::from_setting(Some("branch")),
DoubleEscapeAction::Rewind
);
assert_eq!(
DoubleEscapeAction::from_setting(Some(" Fork ")),
DoubleEscapeAction::Fork
);
assert_eq!(
DoubleEscapeAction::from_setting(Some("none")),
DoubleEscapeAction::None
);
}
#[test]
fn message_picker_lists_newest_first_and_routes_the_pick() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/branch");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::MessagePicker { fork: false }
);
let messages = vec![
(String::from("first question"), String::from("id-1")),
(String::from("second question"), String::from("id-2")),
];
sim.send(PiFtuiMsg::Agent(PiMsg::MessagePicker {
fork: false,
messages: messages.clone(),
}));
let frame = buffer_text(sim.capture_frame(100, 24), 100, 24);
let newest = frame.find("2. second question").expect("newest listed");
let oldest = frame.find("1. first question").expect("oldest listed");
assert!(newest < oldest, "{frame}");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::RewindTo {
entry_id: String::from("id-2")
}
);
sim.send(PiFtuiMsg::Agent(PiMsg::MessagePicker {
fork: true,
messages,
}));
sim.inject_event(key(KeyCode::Down, Modifiers::empty()));
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::Fork {
args: String::from("id-1")
}
);
sim.send(PiFtuiMsg::Agent(PiMsg::MessagePicker {
fork: false,
messages: Vec::new(),
}));
assert!(sim.model().picker.is_none());
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.text == "No messages to branch from")
);
}
#[test]
fn rewind_moves_the_leaf_back_and_returns_the_message_to_the_editor() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("runtime");
let cwd = tempfile::tempdir().expect("tempdir");
runtime.block_on(async {
let mut handle = crate::sdk::create_agent_session(crate::sdk::SessionOptions {
provider: Some(String::from("openai")),
model: Some(String::from("gpt-4o")),
api_key: Some(String::from("dummy-key")),
working_directory: Some(cwd.path().to_path_buf()),
no_session: true,
..crate::sdk::SessionOptions::default()
})
.await
.expect("create session");
{
let store = handle.session_store();
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = store.lock(cx.cx()).await.expect("session lock");
for text in ["first question", "second question"] {
session.append_message(crate::session::SessionMessage::User {
content: crate::model::UserContent::Text(String::from(text)),
timestamp: Some(0),
});
session.append_message(crate::session::SessionMessage::from(
crate::model::Message::Assistant(Arc::new(
crate::model::AssistantMessage {
content: vec![crate::model::ContentBlock::Text(
crate::model::TextContent::new("answer"),
)],
..Default::default()
},
)),
));
}
}
let (agent_tx, agent_rx) = mpsc::channel::<PiMsg>();
send_message_picker(&handle, false, &agent_tx).await;
let Ok(PiMsg::MessagePicker { messages, .. }) = agent_rx.try_recv() else {
panic!("expected the message list");
};
let summaries: Vec<&str> = messages.iter().map(|(s, _)| s.as_str()).collect();
assert_eq!(summaries, ["first question", "second question"]);
run_rewind_command(&mut handle, &messages[1].1, &agent_tx).await;
let replies: Vec<PiMsg> = agent_rx.try_iter().collect();
let Some(PiMsg::ConversationReset {
messages: shown, ..
}) = replies.first()
else {
panic!("expected a conversation reset, got {replies:?}");
};
assert_eq!(shown.len(), 2, "first question and its answer remain");
assert!(
replies.iter().any(|msg| matches!(
msg,
PiMsg::SetEditorText { text, .. } if text == "second question"
)),
"{replies:?}"
);
assert_eq!(handle.session().agent.messages().len(), 2);
run_rewind_command(&mut handle, "no-such-id", &agent_tx).await;
assert!(matches!(
agent_rx.try_recv(),
Ok(PiMsg::AgentError(text)) if text.starts_with("rewind:")
));
});
}
#[test]
fn fast_slash_command_routes_to_driver() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
for (typed, request) in [
("/fast", ToggleRequest::Toggle),
("/fast on", ToggleRequest::On),
("/fast OFF", ToggleRequest::Off),
("/fast status", ToggleRequest::Status),
] {
type_str(&mut sim, typed);
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::Fast(request),
"{typed}"
);
}
type_str(&mut sim, "/fast turbo");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(submit_rx.try_recv().is_err());
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.role == EntryRole::Error && e.text.contains("Usage: /fast"))
);
}
#[test]
fn skillful_slash_command_routes_to_driver() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
for (typed, request) in [
("/skillful", ToggleRequest::Toggle),
("/skillful toggle", ToggleRequest::Toggle),
("/skillful on", ToggleRequest::On),
("/skillful OFF", ToggleRequest::Off),
("/skillful status", ToggleRequest::Status),
] {
type_str(&mut sim, typed);
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::Skillful(request),
"{typed}"
);
}
type_str(&mut sim, "/skillful maybe");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(submit_rx.try_recv().is_err());
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.role == EntryRole::Error && e.text.contains("Usage: /skillful"))
);
}
#[test]
fn bash_ui_command_uses_configured_shell_and_prefix() {
let config = crate::config::Config {
shell_path: Some(String::from("/bin/sh")),
shell_command_prefix: Some(String::from("PI_GH182=prefixed")),
..crate::config::Config::default()
};
let shell = BashUiShell::from_config(&config);
assert_eq!(shell.path.as_deref(), Some("/bin/sh"));
let dir = tempfile::tempdir().expect("tempdir");
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("runtime");
let (agent_tx, _agent_rx) = mpsc::channel();
let output = runtime.block_on(run_bash_ui_command(
dir.path(),
&shell,
"echo \"value=$PI_GH182\"",
false,
&agent_tx,
));
let output = output.expect("command ran");
assert!(output.contains("value=prefixed"), "{output}");
let missing = BashUiShell {
path: Some(String::from("/nonexistent/pi-gh182-shell")),
command_prefix: None,
};
let (agent_tx, agent_rx) = mpsc::channel();
let output = runtime.block_on(run_bash_ui_command(
dir.path(),
&missing,
"true",
false,
&agent_tx,
));
assert!(output.is_none());
let errors: Vec<String> = agent_rx
.try_iter()
.filter_map(|msg| match msg {
PiMsg::AgentError(err) => Some(err),
_ => None,
})
.collect();
assert!(
errors.iter().any(|err| err.contains("pi-gh182-shell")),
"{errors:?}"
);
}
fn windows_keystroke(sim: &mut ProgramSimulator<PiFtuiModel>, code: KeyCode) {
sim.inject_event(key(code, Modifiers::empty()));
sim.inject_event(Event::Key(KeyEvent {
code,
modifiers: Modifiers::empty(),
kind: KeyEventKind::Release,
}));
}
fn windows_type_str(sim: &mut ProgramSimulator<PiFtuiModel>, s: &str) {
for ch in s.chars() {
windows_keystroke(sim, KeyCode::Char(ch));
}
}
#[test]
fn windows_key_releases_do_not_confirm_or_skip_in_the_model_picker() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx)
.with_submit_channel(submit_tx)
.with_available_models(vec![
String::from("openai/gpt-5"),
String::from("anthropic/claude-opus-5"),
String::from("google/gemini-3-pro"),
]);
let mut sim = ProgramSimulator::new(model);
sim.init();
windows_type_str(&mut sim, "/model");
assert_eq!(sim.model().input.text(), "/model", "typed text doubled");
windows_keystroke(&mut sim, KeyCode::Enter);
assert!(
sim.model().picker.is_some(),
"the Enter release confirmed the picker the press had just opened"
);
assert!(
submit_rx.try_recv().is_err(),
"no model may be chosen before the user picks one"
);
windows_keystroke(&mut sim, KeyCode::Down);
let rendered = buffer_text(sim.capture_frame(50, 10), 50, 10);
assert!(
rendered.contains("▸ anthropic/claude-opus-5"),
"one Down keystroke must move exactly one row: {rendered:?}"
);
windows_keystroke(&mut sim, KeyCode::Enter);
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::SetModel {
provider: "anthropic".into(),
model: "claude-opus-5".into(),
}
);
assert!(submit_rx.try_recv().is_err(), "exactly one selection");
assert!(sim.model().picker.is_none());
}
#[test]
fn windows_key_releases_do_not_skip_completion_rows() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
windows_type_str(&mut sim, "/");
assert!(sim.model().completion_visible(), "slash opens the popup");
assert!(sim.model().autocomplete.items.len() >= 4);
windows_keystroke(&mut sim, KeyCode::Down);
assert_eq!(sim.model().autocomplete.selected, Some(0));
windows_keystroke(&mut sim, KeyCode::Down);
assert_eq!(sim.model().autocomplete.selected, Some(1));
sim.inject_event(Event::Key(KeyEvent {
code: KeyCode::Down,
modifiers: Modifiers::empty(),
kind: KeyEventKind::Repeat,
}));
assert_eq!(
sim.model().autocomplete.selected,
Some(2),
"auto-repeat is still input"
);
}
#[test]
fn slash_undo_redo_usage_route_commands() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/undo 3 force");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::Undo {
count: 3,
force: true,
redo: false
}
);
type_str(&mut sim, "/redo");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::Undo {
count: 1,
force: false,
redo: true
}
);
type_str(&mut sim, "/usage refresh");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::Usage { refresh: true }
);
type_str(&mut sim, "/undo everything");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(submit_rx.try_recv().is_err(), "no command for bad args");
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.text.contains("usage: /undo")),
"usage error shown"
);
}
#[test]
fn strip_command_requires_exact_name_or_space() {
assert_eq!(strip_command("/undo", "/undo"), Some(""));
assert_eq!(strip_command("/UNDO 2", "/undo"), Some("2"));
assert_eq!(strip_command("/undo 2", "/undo"), Some("2"));
assert_eq!(strip_command("/undocumented", "/undo"), None);
assert_eq!(strip_command("/usage", "/usage"), Some(""));
}
#[test]
fn slash_compact_routes_command() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/compact");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(submit_rx.try_recv().expect("routed"), UiCommand::Compact);
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.text.contains("compacting")),
"compact note missing"
);
}
#[test]
fn slash_tan_routes_command() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/tan summarise the changelog");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::Tan("summarise the changelog".to_string())
);
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.text.contains("(/tan started)")),
"the user must be told the background job started"
);
assert_eq!(
sim.model().state,
AgentUiState::Ready,
"/tan must NOT put the session in a working state — the point is \
that the user keeps working while the child runs"
);
}
#[test]
fn slash_tan_without_work_is_refused_before_the_driver() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/tan");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(
submit_rx.try_recv().is_err(),
"an empty /tan must never reach the driver"
);
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.text.contains("Usage: /tan")),
"the refusal must say how to use it"
);
}
#[test]
fn a_background_note_arriving_mid_turn_waits_for_the_turn_boundary() {
let (_agent_tx, rx) = mpsc::channel();
let mut sim = ProgramSimulator::new(PiFtuiModel::new(rx));
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::ConversationReset {
session_id: "s1".to_string(),
messages: Vec::new(),
usage: crate::model::Usage::default(),
status: None,
}));
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::TextDelta("partial".to_string())));
sim.send(PiFtuiMsg::Agent(PiMsg::SessionSystemNote {
owner_session_id: "s1".to_string(),
message: "(/tan completed)\nbackground answer".to_string(),
}));
assert!(
!sim.model()
.transcript
.iter()
.any(|e| e.text.contains("background answer")),
"the note must not interleave with the streaming reply"
);
sim.send(PiFtuiMsg::Agent(PiMsg::AgentDone {
usage: None,
stop_reason: crate::model::StopReason::Stop,
error_message: None,
}));
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.text.contains("background answer")),
"the turn ended, so the held note is owed to the user now"
);
}
#[test]
fn slash_share_routes_command() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/share");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(submit_rx.try_recv().expect("routed"), UiCommand::Share);
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.text.contains("Sharing session")),
"share note missing"
);
}
#[test]
fn slash_login_and_logout_route_to_the_driver() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/login anthropic");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("login routed"),
UiCommand::Login {
args: String::from("anthropic")
}
);
type_str(&mut sim, "/logout");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("logout routed"),
UiCommand::Logout {
args: String::new()
}
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn reload_picks_up_an_edited_extension_and_keeps_the_conversation() {
let runtime = asupersync::runtime::RuntimeBuilder::new()
.build()
.expect("runtime");
let runtime_handle = runtime.handle();
let cwd = tempfile::tempdir().expect("cwd");
let sessions = tempfile::tempdir().expect("sessions");
let extension = cwd.path().join("reload-probe.mjs");
let write_extension = |command: &str| {
std::fs::write(
&extension,
format!(
"export default function init(pi) {{\n\
pi.registerCommand(\"{command}\", {{ description: \"probe\", handler: async (args) => ({{command: \"{command}\", args}}) }});\n\
}}\n"
),
)
.expect("write extension");
};
write_extension("Plan");
let (agent_tx, agent_rx) = mpsc::channel::<PiMsg>();
let registry: ExtensionCommandRegistry = Arc::default();
let ext_handler = Arc::new(FtuiExtensionUiHandler {
command_registry: Arc::downgrade(®istry),
..FtuiExtensionUiHandler::new(agent_tx.clone())
});
let (_model_tx, model_rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel();
let mut model = PiFtuiModel::new(model_rx)
.with_submit_channel(submit_tx)
.with_extension_command_registry(registry);
let template = resume_template_from(&crate::sdk::SessionOptions {
provider: Some(String::from("openai")),
model: Some(String::from("gpt-4o")),
api_key: Some(String::from("dummy-key")),
working_directory: Some(cwd.path().to_path_buf()),
workspace_trusted: true,
session_dir: Some(sessions.path().to_path_buf()),
extension_paths: vec![extension.clone()],
..crate::sdk::SessionOptions::default()
});
let current_ask: CurrentAsk = Arc::new(Mutex::new(None));
runtime.block_on(async move {
let mut handle = crate::sdk::create_agent_session_deferred_mcp(replacement_options(
&template,
&ext_handler,
&runtime_handle,
))
.await
.expect("create session");
assert!(
handle
.extension_manager()
.is_some_and(|manager| manager.has_command("Plan"))
);
ext_handler.install_command_registry(&handle);
let mut catalog = AutocompleteCatalog::default();
refresh_extension_commands(&handle, &mut catalog, &agent_tx);
assert!(
catalog
.extension_commands
.iter()
.any(|entry| entry.name == "Plan")
);
assert!(model.route_slash_command("/PLAN Keep These ARGs"));
let UiCommand::RegisteredExtensionCommand { name, args } =
submit_rx.try_recv().expect("extension route")
else {
panic!("registered /plan must precede the built-in");
};
run_extension_command(&handle, cwd.path(), &name, &args, true, &agent_tx).await;
assert!(
agent_rx.try_iter().any(|message| {
matches!(message, PiMsg::SystemNote(text)
if text.contains(r#""command":"Plan""#)
&& text.contains(r#""args":"Keep These ARGs""#))
}),
"the real registered handler must receive its original arguments"
);
assert!(model.route_slash_command("/plan stale-before-reload"));
let stale = submit_rx.try_recv().expect("queued before reload");
let session_id = {
let store = handle.session_store();
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = store.lock(cx.cx()).await.expect("session lock");
session.append_message(crate::session::SessionMessage::User {
content: crate::model::UserContent::Text(String::from("keep me")),
timestamp: Some(0),
});
session.save().await.expect("save session");
session.header.id.clone()
};
write_extension("status");
reload_session_command(
&template,
&mut handle,
¤t_ask,
&ext_handler,
&agent_tx,
&runtime_handle,
)
.await
.expect("reload");
let manager = handle.extension_manager().expect("extensions after reload");
assert!(
manager.has_command("status"),
"the edited extension must load"
);
assert!(!manager.has_command("Plan"), "the old version must be gone");
assert!(model.route_slash_command("/status after reload"));
let UiCommand::RegisteredExtensionCommand { name, args } =
submit_rx.try_recv().expect("new live extension route")
else {
panic!("reloaded /status must precede the built-in");
};
run_extension_command(&handle, cwd.path(), &name, &args, true, &agent_tx).await;
assert!(model.route_slash_command("/plan status"));
assert_eq!(
submit_rx
.try_recv()
.expect("removed override restores built-in"),
UiCommand::Plan {
action: String::from("status")
}
);
refresh_extension_commands(&handle, &mut catalog, &agent_tx);
assert!(
catalog
.extension_commands
.iter()
.any(|entry| entry.name == "status")
);
assert!(
!catalog
.extension_commands
.iter()
.any(|entry| entry.name == "Plan")
);
let (id, kept) = handle
.with_session(|session| {
let kept = session
.to_messages_for_current_path()
.iter()
.any(|message| matches!(
message,
crate::model::Message::User(user)
if matches!(&user.content, crate::model::UserContent::Text(text) if text == "keep me")
));
(session.header.id.clone(), kept)
})
.await
.expect("session snapshot");
assert_eq!(id, session_id, "reload keeps the same session");
assert!(kept, "reload keeps the conversation");
let replies = agent_rx.try_iter().collect::<Vec<_>>();
assert!(
replies
.iter()
.any(|msg| matches!(msg, PiMsg::System(text) if text.starts_with("Reloaded"))),
"the user is told the reload happened"
);
assert!(
replies.iter().any(|message| {
matches!(message, PiMsg::SystemNote(text)
if text.contains(r#""command":"status""#)
&& text.contains(r#""args":"after reload""#))
}),
"the replacement runtime executes the newly registered command"
);
let UiCommand::RegisteredExtensionCommand { name, args } = stale else {
panic!("queued registration retains strict dispatch");
};
run_extension_command(&handle, cwd.path(), &name, &args, true, &agent_tx).await;
let stale_replies = agent_rx.try_iter().collect::<Vec<_>>();
assert!(
!stale_replies
.iter()
.any(|message| matches!(message, PiMsg::ToolStart { .. }))
);
assert!(
stale_replies.iter().any(|message| {
matches!(message, PiMsg::System(text)
if text.starts_with("Extension command '/Plan' is no longer registered"))
}),
"an obsolete queued command must be refused, not reinterpreted"
);
let mut unsaved = crate::sdk::create_agent_session_deferred_mcp({
let mut options = replacement_options(&template, &ext_handler, &runtime_handle);
options.no_session = true;
options
})
.await
.expect("in-memory session");
{
let store = unsaved.session_store();
let cx = crate::agent_cx::AgentCx::for_request();
let mut session = store.lock(cx.cx()).await.expect("session lock");
session.path = None;
session.append_message(crate::session::SessionMessage::User {
content: crate::model::UserContent::Text(String::from("unsaved")),
timestamp: Some(0),
});
}
let unsaved_id = unsaved
.with_session(|session| session.header.id.clone())
.await
.expect("id");
reload_session_command(
&template,
&mut unsaved,
¤t_ask,
&ext_handler,
&agent_tx,
&runtime_handle,
)
.await
.expect("refusal is not a driver failure");
assert_eq!(
unsaved
.with_session(|session| session.header.id.clone())
.await
.expect("id"),
unsaved_id,
"an unsaved conversation must not be replaced"
);
assert!(
agent_rx
.try_iter()
.any(|msg| matches!(msg, PiMsg::AgentError(text) if text.contains("not saved"))),
"the refusal is reported"
);
let _ = unsaved.shutdown_owned_resources().await;
let _ = handle.shutdown_owned_resources().await;
});
}
fn unroutable_btw_client() -> Arc<crate::btw::BtwClient> {
Arc::new(crate::btw::BtwClient::new(
Arc::new(
crate::providers::openai::OpenAIProvider::new("btw-fixture")
.with_base_url("http://127.0.0.1:1/v1"),
),
None,
))
}
#[test]
fn slash_btw_routes_when_a_smol_client_exists_and_refuses_otherwise() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let mut model = PiFtuiModel::new(rx)
.with_submit_channel(submit_tx)
.with_btw_client(Some(unroutable_btw_client()));
model.displayed_session_id = Some(String::from("btw-session"));
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/btw what is a monad");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::Btw {
question: String::from("what is a monad"),
owner_session_id: String::from("btw-session"),
}
);
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let mut sim = ProgramSimulator::new(PiFtuiModel::new(rx).with_submit_channel(submit_tx));
sim.init();
type_str(&mut sim, "/btw anything");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(
submit_rx.try_recv().is_err(),
"no smol model: nothing is sent"
);
assert!(
sim.model()
.transcript
.iter()
.any(|entry| entry.text == BTW_UNAVAILABLE),
"the refusal says why"
);
}
#[test]
fn slash_btw_mid_turn_waits_for_live_privacy_projection() {
let slot: TurnControlSlot = Arc::new(Mutex::new(None));
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let mut model = PiFtuiModel::new(rx)
.with_submit_channel(submit_tx)
.with_turn_control(slot)
.with_btw_client(Some(unroutable_btw_client()));
model.displayed_session_id = Some(String::from("busy-session"));
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
type_str(&mut sim, "/btw quick check");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx
.try_recv()
.expect("question queued for the driver"),
UiCommand::Btw {
question: String::from("quick check"),
owner_session_id: String::from("busy-session"),
}
);
assert!(sim.model().pending_task.is_none());
assert!(sim.model().input.text().is_empty());
assert!(
sim.model().transcript.iter().any(|entry| entry
.text
.contains("queued until the current turn finishes")),
"the note explains when the question will be answered"
);
}
#[test]
fn side_question_queue_bounds_raw_input_and_requires_session_identity() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let mut model = PiFtuiModel::new(rx)
.with_submit_channel(submit_tx)
.with_btw_client(Some(unroutable_btw_client()));
model.queue_btw("a question before the session has loaded");
assert!(submit_rx.try_recv().is_err());
model.displayed_session_id = Some(String::from("busy-session"));
model.state = AgentUiState::Working;
model.queue_btw(&"x".repeat(crate::text_completion::MAX_INPUT_BYTES));
assert!(submit_rx.try_recv().is_err());
assert!(model.pending_task.is_none());
assert!(
model
.transcript
.last()
.is_some_and(|entry| entry.text.contains("privacy scan budget"))
);
}
#[derive(Default)]
struct BtwPrivacyProbe {
prompts: Mutex<Vec<String>>,
}
#[async_trait::async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl crate::provider::Provider for BtwPrivacyProbe {
fn name(&self) -> &str {
"btw-privacy"
}
fn api(&self) -> &str {
"test"
}
fn model_id(&self) -> &str {
"btw-privacy"
}
async fn stream(
&self,
context: &crate::provider::Context<'_>,
_options: &crate::provider::StreamOptions,
) -> crate::error::Result<
std::pin::Pin<
Box<
dyn futures::Stream<Item = crate::error::Result<crate::model::StreamEvent>>
+ Send,
>,
>,
> {
assert!(context.tools.is_empty());
let crate::model::Message::User(user) = &context.messages[0] else {
panic!("expected auxiliary user message");
};
let crate::model::UserContent::Text(text) = &user.content else {
panic!("expected auxiliary text projection");
};
self.prompts.lock().unwrap().push(text.clone());
Ok(Box::pin(futures::stream::iter([Ok(
crate::model::StreamEvent::Done {
reason: StopReason::Stop,
message: crate::model::AssistantMessage {
content: vec![crate::model::ContentBlock::Text(
crate::model::TextContent::new("screened side answer"),
)],
..Default::default()
},
},
)])))
}
}
#[test]
#[allow(clippy::significant_drop_tightening, clippy::too_many_lines)]
fn side_question_driver_rejects_stale_owners_and_screens_remembered_credentials() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("runtime");
let runtime_handle = runtime.handle();
runtime.block_on(async {
let provider = Arc::new(BtwPrivacyProbe::default());
let known = "previouslyLearnedCredentialValue123456";
let mut agent = crate::agent::Agent::new(
provider.clone(),
crate::tools::ToolRegistry::from_tools(Vec::new()),
crate::agent::AgentConfig::default(),
);
agent
.secrets_transform_outbound_text(&format!("API_KEY={known}"))
.unwrap();
let session = crate::session::Session::in_memory();
let owner_session_id = session.header.id.clone();
let session = crate::agent::AgentSession::new(
agent,
Arc::new(asupersync::sync::Mutex::new(session)),
false,
crate::compaction::ResolvedCompactionSettings::default(),
);
let handle = crate::sdk::AgentSessionHandle::from_session_with_listeners(
session,
crate::sdk::EventListeners::default(),
);
let blocked_client = Arc::new(
crate::btw::BtwClient::new(provider.clone(), None).with_secrets_settings(Some(
&crate::secrets::SecretsSettings {
mode: Some(String::from("block")),
extra_patterns: None,
},
)),
);
let (agent_tx, agent_rx) = mpsc::channel();
run_btw_command(
&handle,
Some(&blocked_client),
String::from("a clean but stale question"),
String::from("replaced-session"),
&agent_tx,
&runtime_handle,
)
.await;
assert!(agent_rx.try_recv().is_err());
run_btw_command(
&handle,
Some(&blocked_client),
format!("Does {known} still work?"),
owner_session_id.clone(),
&agent_tx,
&runtime_handle,
)
.await;
assert!(matches!(
agent_rx.try_recv().expect("privacy refusal"),
PiMsg::AgentError(message)
if message.starts_with("/btw refused:") && !message.contains(known)
));
assert!(provider.prompts.lock().unwrap().is_empty());
let redacting_client = Arc::new(crate::btw::BtwClient::new(provider.clone(), None));
run_btw_command(
&handle,
Some(&redacting_client),
format!("Does {known} still work?"),
owner_session_id.clone(),
&agent_tx,
&runtime_handle,
)
.await;
let answer = asupersync::time::timeout(
asupersync::time::wall_now(),
Duration::from_secs(5),
async {
loop {
if let Ok(message) = agent_rx.try_recv() {
break message;
}
asupersync::time::sleep(
asupersync::time::wall_now(),
Duration::from_millis(1),
)
.await;
}
},
)
.await
.expect("screened side request completes");
assert!(matches!(
answer,
PiMsg::SessionSystemNote { owner_session_id: owner, message }
if owner == owner_session_id && message == "(/btw) screened side answer"
));
let prompts = provider.prompts.lock().unwrap();
assert_eq!(prompts.len(), 1);
assert!(!prompts[0].contains(known));
assert!(prompts[0].contains("<pi-secret:redacted>"));
assert!(handle.session().agent.messages().is_empty());
assert!(
handle
.session()
.session
.try_lock()
.unwrap()
.entries
.is_empty()
);
});
}
#[test]
fn omp_info_commands_and_aliases_route_to_the_driver() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
let mut send = |text: &str| {
type_str(&mut sim, text);
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
submit_rx.try_recv().expect("routed")
};
for (text, expected) in [
("/tools", info_commands::InfoCommand::Tools),
("/extensions", info_commands::InfoCommand::Extensions),
("/skills", info_commands::InfoCommand::Skills),
("/dirs", info_commands::InfoCommand::Dirs),
("/context", info_commands::InfoCommand::Context),
("/todo", info_commands::InfoCommand::Todo),
("/jobs", info_commands::InfoCommand::Jobs),
("/stats", info_commands::InfoCommand::Stats),
] {
assert_eq!(send(text), UiCommand::Info(expected), "{text}");
}
assert_eq!(
send("/plan-review"),
UiCommand::Plan {
action: String::from("review")
}
);
assert_eq!(
send("/rename release prep"),
UiCommand::SetName(String::from("release prep"))
);
assert_eq!(send("/fresh"), UiCommand::Fresh);
assert_eq!(send("/retry"), UiCommand::Retry);
assert_eq!(send("/shake"), UiCommand::Shake);
assert_eq!(
send("/template"),
UiCommand::Info(info_commands::InfoCommand::Templates)
);
assert_eq!(
send("/template fix src/a.rs"),
UiCommand::ExtensionCommand {
name: String::from("fix"),
args: String::from("src/a.rs")
}
);
assert_eq!(send("/compact shake"), UiCommand::Shake);
assert_eq!(
send("/checkpoint before-refactor risky part"),
UiCommand::Checkpoint {
args: String::from("before-refactor risky part")
}
);
assert_eq!(
send("/rewind"),
UiCommand::Rewind {
name: String::new()
}
);
assert_eq!(
send("/commit --dry-run"),
UiCommand::Workspace {
command: workspace_commands::WorkspaceCommand::Commit,
args: String::from("--dry-run")
}
);
assert_eq!(
send("/advisor"),
UiCommand::Workspace {
command: workspace_commands::WorkspaceCommand::Advisor,
args: String::new()
}
);
assert_eq!(
send("/toolsy"),
UiCommand::ExtensionCommand {
name: String::from("toolsy"),
args: String::new()
},
"a near-miss token is an extension command, not /tools"
);
}
#[test]
fn slash_fork_routes_and_the_selected_text_returns_to_the_editor() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/fork 2");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("fork routed"),
UiCommand::Fork {
args: String::from("2")
}
);
sim.send(PiFtuiMsg::Agent(PiMsg::ConversationReset {
session_id: String::from("forked"),
messages: Vec::new(),
usage: crate::model::Usage::default(),
status: None,
}));
sim.send(PiFtuiMsg::Agent(PiMsg::SetEditorText {
owner_session_id: String::from("some-other-session"),
text: String::from("stale"),
}));
assert_eq!(sim.model().input.text(), "", "a stale hand-back is dropped");
sim.send(PiFtuiMsg::Agent(PiMsg::SetEditorText {
owner_session_id: String::from("forked"),
text: String::from("reword me"),
}));
assert_eq!(sim.model().input.text(), "reword me");
}
#[test]
fn returned_control_input_preserves_draft_and_ignores_stale_sessions() {
let (_unused, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::ConversationReset {
session_id: String::from("current"),
messages: Vec::new(),
usage: crate::model::Usage::default(),
status: None,
}));
type_str(&mut sim, "existing draft");
sim.send(PiFtuiMsg::Agent(PiMsg::RestorePendingInput {
owner_session_id: String::from("previous"),
text: String::from("stale controls"),
}));
assert_eq!(
sim.model().input.text(),
"existing draft"
);
sim.send(PiFtuiMsg::Agent(PiMsg::RestorePendingInput {
owner_session_id: String::from("current"),
text: String::from("first control\n\nsecond control"),
}));
sim.send(PiFtuiMsg::Agent(PiMsg::RestorePendingInput {
owner_session_id: String::from("current"),
text: String::new(),
}));
assert_eq!(
sim.model().input.text(),
"first control\n\nsecond control\n\nexisting draft"
);
sim.send(PiFtuiMsg::Agent(PiMsg::SetEditorText {
owner_session_id: String::from("current"),
text: String::from("explicit replacement"),
}));
assert_eq!(
sim.model().input.text(),
"explicit replacement"
);
}
#[test]
fn build_fork_session_branches_before_the_selected_message() {
use crate::model::UserContent;
use crate::session::{Session, SessionMessage};
let dir = tempfile::tempdir().expect("tempdir");
let mut source = Session::create_with_dir(Some(dir.path().to_path_buf()));
source.header.thinking_level = Some(String::from("high"));
for text in ["first question", "second question"] {
source.append_message(SessionMessage::User {
content: UserContent::Text(text.to_string()),
timestamp: Some(0),
});
}
source.ensure_entry_ids();
let candidates = crate::interactive::fork_candidates(&source);
assert_eq!(candidates.len(), 2);
let second = crate::interactive::select_fork_candidate(&candidates, "2").expect("select");
let (forked, selected_text) = build_fork_session(
&source,
&second.id,
String::from("anthropic"),
String::from("claude-test"),
)
.expect("build fork");
assert_eq!(selected_text, "second question");
let texts = forked
.to_messages_for_current_path()
.iter()
.filter_map(|message| match message {
crate::model::Message::User(user) => match &user.content {
UserContent::Text(text) => Some(text.clone()),
UserContent::Blocks(_) => None,
},
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(texts, vec![String::from("first question")]);
assert_ne!(forked.header.id, source.header.id);
assert_eq!(forked.header.provider.as_deref(), Some("anthropic"));
assert_eq!(forked.header.model_id.as_deref(), Some("claude-test"));
assert_eq!(forked.header.thinking_level.as_deref(), Some("high"));
assert_eq!(forked.session_dir.as_deref(), Some(dir.path()));
}
#[test]
fn ftui_fork_preserves_a_lazy_sources_workspace_and_resumed_sdk_context() {
let temp = tempfile::tempdir().expect("tempdir");
let reactor = asupersync::runtime::reactor::create_reactor().expect("reactor");
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.with_reactor(reactor)
.build()
.expect("runtime");
runtime.block_on(Box::pin(async {
let fixture = crate::session::fork_tests::workspace_fixture(temp.path(), true).await;
let (mut fork, selected_text) = build_fork_session(
&fixture.source,
&fixture.target_id,
"anthropic".to_string(),
"test-model".to_string(),
)
.expect("build FTUI fork");
assert_eq!(selected_text, "revise this request");
fixture.assert_workspace(&fork);
fixture.assert_selected_context(&fork);
fork.save().await.expect("persist FTUI fork");
let mut workspace = crate::workspace::WorkspaceHandle::single(&fixture.bound_cwd);
workspace.add_root(&fixture.additional_root);
let options = crate::sdk::SessionOptions {
provider: Some("openai".to_string()),
model: Some("gpt-4o".to_string()),
api_key: Some("fork-workspace-test-key".to_string()),
session_path: fork.path.clone(),
working_directory: Some(fixture.bound_cwd.clone()),
workspace: Some(workspace),
workspace_trusted: true,
enabled_tools: Some(vec!["write".to_string()]),
..crate::sdk::SessionOptions::default()
};
let handle = crate::sdk::create_agent_session(options)
.await
.expect("resume FTUI fork in its attached workspace");
assert!(
handle
.session()
.agent
.system_prompt()
.unwrap()
.contains(crate::session::fork_tests::WORKSPACE_CONTEXT)
);
let tools = handle.session().agent.shared_tools().snapshot();
let output = tools
.get("write")
.expect("write tool")
.execute(
"fork-workspace-write",
serde_json::json!({"path": "fork-continuation.txt", "content": "continued fork"}),
None,
)
.await
.expect("write from resumed fork");
assert!(!output.is_error);
assert_eq!(
std::fs::read_to_string(fixture.bound_cwd.join("fork-continuation.txt"))
.expect("fork output in bound workspace"),
"continued fork"
);
assert!(!fixture.original_cwd.exists());
assert!(handle.shutdown_owned_resources().await.completed_cleanly());
assert_eq!(
std::fs::read(&fixture.source_file).expect("unchanged source"),
fixture.source_bytes
);
}));
}
#[test]
fn pending_login_input_reaches_the_driver_without_being_echoed() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::LoginPending {
provider: Some(String::from("openai")),
accepts_empty_input: false,
}));
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(
submit_rx.try_recv().is_err(),
"an empty line must not submit"
);
type_str(&mut sim, "sk-very-secret");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("login input routed"),
UiCommand::LoginSubmit(LoginInput(String::from("sk-very-secret")))
);
assert!(
sim.model()
.transcript
.iter()
.all(|entry| !entry.text.contains("sk-very-secret")),
"the key must never be echoed"
);
assert!(
format!(
"{:?}",
UiCommand::LoginSubmit(LoginInput(String::from("sk-very-secret")))
)
.contains("<redacted>"),
"Debug must not print the key"
);
type_str(&mut sim, "/session");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("slash command routed"),
UiCommand::SessionInfo
);
assert!(sim.model().login_pending.is_none());
type_str(&mut sim, "hello");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("prompt routed"),
UiCommand::Prompt(String::from("hello")),
"after cancelling, input is an ordinary prompt again"
);
}
#[test]
fn slash_share_refuses_an_argument_without_reaching_the_driver() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/share public");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(
submit_rx.try_recv().is_err(),
"/share public must never reach the driver"
);
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.text.contains("public sharing is disabled")),
"the refusal must say why"
);
}
#[test]
fn slash_exit_quits() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/exit");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(!sim.is_running(), "/exit did not quit");
}
#[test]
fn bare_model_command_errors_without_registry() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/model");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(sim.model().picker.is_none());
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.role == EntryRole::Error && e.text.contains("no models available")),
"empty-registry error missing"
);
}
#[test]
fn resume_picker_shows_labels_and_routes_paths() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx)
.with_submit_channel(submit_tx)
.with_available_sessions(vec![
(
String::from("fix parser · 12 msgs"),
String::from("/tmp/sessions/a.jsonl"),
),
(
String::from("older run · 3 msgs"),
String::from("/tmp/sessions/b.jsonl"),
),
]);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/resume");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
let rendered = buffer_text(sim.capture_frame(50, 10), 50, 10);
assert!(
rendered.contains("▸ fix parser · 12 msgs"),
"labels not shown: {rendered:?}"
);
assert!(
!rendered.contains("/tmp/sessions"),
"paths leaked into display: {rendered:?}"
);
sim.inject_event(key(KeyCode::Char('j'), Modifiers::empty()));
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::ResumeSession {
path: "/tmp/sessions/b.jsonl".into()
}
);
}
#[test]
fn slash_retry_routes_and_the_commit_replays_the_branch() {
use crate::interactive::{ConversationMessage, MessageRole};
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let mut sim = ProgramSimulator::new(PiFtuiModel::new(rx).with_submit_channel(submit_tx));
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::System("abandoned reply".into())));
type_str(&mut sim, "/retry");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(submit_rx.try_recv().expect("routed"), UiCommand::Retry);
sim.send(PiFtuiMsg::Agent(PiMsg::RetryCommitted {
session_id: "s".into(),
messages: vec![ConversationMessage {
role: MessageRole::User,
content: "earlier".into(),
thinking: None,
collapsed: false,
}],
usage: crate::model::Usage::default(),
text: "second question".into(),
status: Some("Retrying last turn".into()),
}));
let transcript = &sim.model().transcript;
assert!(!transcript.iter().any(|e| e.text == "abandoned reply"));
let last = transcript.last().expect("entries");
assert!(last.role == EntryRole::User && last.text == "second question");
}
#[test]
fn conversation_reset_rebuilds_transcript() {
use crate::interactive::{ConversationMessage, MessageRole};
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::System("old line".into())));
sim.send(PiFtuiMsg::Agent(PiMsg::ConversationReset {
session_id: "resumed-session".into(),
messages: vec![
ConversationMessage {
role: MessageRole::User,
content: "restore me".into(),
thinking: None,
collapsed: false,
},
ConversationMessage {
role: MessageRole::Assistant,
content: "restored reply".into(),
thinking: None,
collapsed: false,
},
],
usage: crate::model::Usage::default(),
status: Some("session resumed".into()),
}));
let transcript = &sim.model().transcript;
assert!(
!transcript.iter().any(|e| e.text.contains("old line")),
"stale transcript survived reset"
);
assert!(
transcript
.iter()
.any(|e| e.role == EntryRole::User && e.text == "restore me")
);
assert!(
transcript
.iter()
.any(|e| e.role == EntryRole::Assistant && e.text == "restored reply")
);
assert!(
transcript
.iter()
.any(|e| e.text.contains("session resumed"))
);
sim.send(PiFtuiMsg::Agent(PiMsg::SessionSystemNote {
owner_session_id: "replaced-session".into(),
message: "stale note".into(),
}));
assert!(
!sim.model()
.transcript
.iter()
.any(|entry| entry.text == "stale note"),
"an old session's note must not enter the replacement transcript"
);
sim.send(PiFtuiMsg::Agent(PiMsg::SessionSystemNote {
owner_session_id: "resumed-session".into(),
message: "current note".into(),
}));
assert!(
sim.model()
.transcript
.iter()
.any(|entry| entry.text == "current note"),
"the displayed session's note must remain visible"
);
}
#[test]
fn theme_picker_escape_closes_without_change() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
let accent_before = sim.model().palette.accent;
type_str(&mut sim, "/theme");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
sim.inject_event(key(KeyCode::Escape, Modifiers::empty()));
assert!(sim.model().picker.is_none());
assert_eq!(sim.model().palette.accent, accent_before);
}
#[test]
fn bang_routes_bash_command_and_result_renders() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "!echo hi");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::Bash {
command: "echo hi".into(),
exclude: false,
}
);
type_str(&mut sim, "!!ls");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::Bash {
command: "ls".into(),
exclude: true,
}
);
type_str(&mut sim, "!");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(submit_rx.try_recv().is_err());
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.role == EntryRole::Error && e.text.contains("usage: !")),
"bare-bang usage error missing"
);
sim.send(PiFtuiMsg::Agent(PiMsg::BashResult {
display: "$ echo hi\nhi".into(),
content_for_agent: None,
}));
let rendered = buffer_text(sim.capture_frame(40, 10), 40, 10);
assert!(
rendered.contains("echo hi"),
"bash display missing: {rendered:?}"
);
}
#[test]
fn subscription_id_is_stable() {
let (_tx, rx) = mpsc::channel::<PiMsg>();
let sub = AgentEventSubscription::new(rx);
assert_eq!(sub.id(), AGENT_EVENTS_SUB_ID);
}
#[test]
fn session_slash_commands_route_to_driver() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/new");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(submit_rx.try_recv().expect("routed"), UiCommand::NewSession);
type_str(&mut sim, "/session");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::SessionInfo
);
type_str(&mut sim, "/tree deep --all");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::TreeSummary
);
type_str(&mut sim, "/thinking medium");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::SetThinking(Some(crate::model::ThinkingLevel::Medium))
);
type_str(&mut sim, "/t 3");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::SetThinking(Some(crate::model::ThinkingLevel::High))
);
type_str(&mut sim, "/think");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::SetThinking(None)
);
type_str(&mut sim, "/thinking bogus");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(submit_rx.try_recv().is_err());
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.role == EntryRole::Error && e.text.contains("Invalid thinking level")),
"invalid-level error missing"
);
type_str(&mut sim, "/name");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(submit_rx.try_recv().is_err());
type_str(&mut sim, "/name ship-it");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::SetName(String::from("ship-it"))
);
type_str(&mut sim, "/mcp");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::Mcp {
subcommand: String::from("list"),
name: None,
}
);
type_str(&mut sim, "/mcp trust docs");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::Mcp {
subcommand: String::from("trust"),
name: Some(String::from("docs")),
}
);
type_str(&mut sim, "/mcp trust");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(submit_rx.try_recv().is_err());
assert!(
sim.model()
.transcript
.iter()
.any(|entry| entry.role == EntryRole::Error && entry.text.contains("usage: /mcp"))
);
}
#[test]
fn slash_input_is_gated_while_working() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
type_str(&mut sim, "/new");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
type_str(&mut sim, "/tree");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(submit_rx.try_recv().is_err());
assert!(sim.model().transcript.is_empty());
}
#[test]
fn clear_resets_transcript_locally() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::System(String::from(
"earlier note",
))));
type_str(&mut sim, "/cls");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
let transcript = &sim.model().transcript;
assert!(!transcript.iter().any(|e| e.text.contains("earlier note")));
assert!(transcript.iter().any(|e| e.text == "Conversation cleared"));
}
#[test]
fn slash_commands_are_case_insensitive_with_aliases() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/M");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.text.contains("no models available")),
"uppercase /M must hit the model path"
);
type_str(&mut sim, "/H");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.text.contains("pi commands")),
"uppercase /H must show help"
);
type_str(&mut sim, "/Q");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(sim.model().pending_quit, "uppercase /Q must quit");
}
#[test]
fn slash_prefix_opens_completion_popup_with_descriptions() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/he");
let model = sim.model();
assert!(
model.completion_visible(),
"a slash prefix must open the popup"
);
assert_eq!(model.autocomplete.items[0].label, "/help");
assert_eq!(
model.autocomplete.selected, None,
"nothing is highlighted until the user navigates"
);
let rendered = buffer_text(sim.capture_frame(80, 12), 80, 12);
assert!(
rendered.contains("/help"),
"popup row missing: {rendered:?}"
);
assert!(
rendered.contains("Show help for interactive commands"),
"description missing: {rendered:?}"
);
assert!(
rendered.contains("Tab/Enter accept"),
"keyboard hint missing: {rendered:?}"
);
}
#[test]
fn plain_text_never_opens_completion_popup() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "hello");
assert!(
!sim.model().autocomplete.open,
"prose must not open the popup"
);
assert_eq!(sim.model().completion_rows(), 0);
type_str(&mut sim, " /he");
assert!(!sim.model().autocomplete.open, "mid-message slash is prose");
let rendered = buffer_text(sim.capture_frame(80, 12), 80, 12);
assert!(
!rendered.contains("Tab/Enter accept"),
"no popup chrome for prose: {rendered:?}"
);
}
#[test]
fn tab_accepts_first_completion_and_enter_then_submits_the_command() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/he");
sim.inject_event(key(KeyCode::Tab, Modifiers::empty()));
assert_eq!(sim.model().input.text(), "/help", "Tab completes the token");
assert!(!sim.model().autocomplete.open, "accepting closes the popup");
assert!(
submit_rx.try_recv().is_err(),
"Tab must not submit anything"
);
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(
sim.model().input.is_empty(),
"Enter submits the completed draft"
);
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.text.contains("pi commands")),
"the completed /help must route like a typed one"
);
}
#[test]
fn arrow_keys_navigate_and_enter_accepts_the_highlighted_row() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/");
assert!(
sim.model().autocomplete.items.len() > 2,
"bare slash lists commands"
);
sim.inject_event(key(KeyCode::Down, Modifiers::empty()));
assert_eq!(sim.model().autocomplete.selected, Some(0));
sim.inject_event(key(KeyCode::Down, Modifiers::empty()));
assert_eq!(sim.model().autocomplete.selected, Some(1));
sim.inject_event(key(KeyCode::Up, Modifiers::empty()));
assert_eq!(sim.model().autocomplete.selected, Some(0));
sim.inject_event(key(KeyCode::Up, Modifiers::empty()));
let last = sim.model().autocomplete.items.len() - 1;
assert_eq!(sim.model().autocomplete.selected, Some(last));
let expected = sim.model().autocomplete.items[last].insert.clone();
let rendered = buffer_text(sim.capture_frame(80, 14), 80, 14);
assert!(
rendered.contains(&format!("▸ {expected}")),
"highlighted row must stay in the rendered window: {rendered:?}"
);
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
sim.model().input.text(),
expected,
"Enter accepts the highlight"
);
assert!(!sim.model().autocomplete.open);
assert!(
submit_rx.try_recv().is_err(),
"accepting a row must not submit the draft"
);
assert!(sim.model().transcript.is_empty());
}
#[test]
fn enter_without_highlight_submits_the_draft_verbatim() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/help");
assert!(
sim.model().completion_visible(),
"exact command still lists matches"
);
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(sim.model().input.is_empty());
assert!(!sim.model().autocomplete.open);
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.text.contains("pi commands")),
"Enter with no highlight submits what was typed"
);
}
#[test]
fn escape_dismisses_popup_and_typing_reopens_it() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/he");
sim.inject_event(key(KeyCode::Escape, Modifiers::empty()));
assert!(!sim.model().autocomplete.open, "Esc closes the popup");
assert_eq!(sim.model().input.text(), "/he", "Esc keeps the draft");
assert_eq!(sim.model().completion_rows(), 0);
type_str(&mut sim, "l");
assert!(sim.model().completion_visible(), "the next edit recomputes");
assert_eq!(sim.model().autocomplete.items[0].label, "/help");
}
#[test]
fn fuzzy_query_still_offers_the_command() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/hlp");
assert!(
sim.model()
.autocomplete
.items
.iter()
.any(|item| item.label == "/help"),
"subsequence matches ride the shared fuzzy matcher: {:?}",
sim.model().autocomplete.items
);
}
#[test]
fn popup_closes_when_the_agent_starts_working() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/he");
assert!(sim.model().completion_visible());
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
assert!(!sim.model().autocomplete.open, "a turn owns the editor");
assert_eq!(sim.model().completion_rows(), 0);
let rendered = buffer_text(sim.capture_frame(80, 12), 80, 12);
assert!(!rendered.contains("Tab/Enter accept"), "{rendered:?}");
}
#[test]
fn catalog_message_makes_extension_commands_completable() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/dep");
assert!(
!sim.model()
.autocomplete
.items
.iter()
.any(|i| i.label == "/deploy"),
"unknown command before the catalog arrives"
);
let catalog = AutocompleteCatalog {
extension_commands: vec![crate::autocomplete::NamedEntry {
name: String::from("deploy"),
description: Some(String::from("Ship the current branch")),
}],
..AutocompleteCatalog::default()
};
sim.send(PiFtuiMsg::Agent(PiMsg::AutocompleteCatalog(catalog)));
assert!(!sim.model().autocomplete.open, "a stale popup is dropped");
type_str(&mut sim, "l");
let model = sim.model();
assert!(model.completion_visible());
assert_eq!(model.autocomplete.items[0].label, "/deploy");
assert_eq!(
model.autocomplete.items[0].kind,
AutocompleteItemKind::ExtensionCommand
);
let rendered = buffer_text(sim.capture_frame(80, 12), 80, 12);
assert!(rendered.contains("Ship the current branch"), "{rendered:?}");
}
#[test]
fn popup_height_caps_at_max_visible_and_scrolls_to_the_highlight() {
let (_agent_tx, rx) = mpsc::channel();
let model = PiFtuiModel::new(rx).with_autocomplete(AutocompleteLaunch {
catalog: AutocompleteCatalog::default(),
cwd: std::path::PathBuf::from("."),
max_visible: 3,
resources: None,
resource_source: None,
});
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.inject_event(Event::Resize {
width: 80,
height: 20,
});
let body_before = sim.model().body_height();
type_str(&mut sim, "/");
let total = sim.model().autocomplete.items.len();
assert!(total > 3, "need more commands than rows for this test");
assert_eq!(sim.model().completion_rows(), 4, "3 rows + hint");
assert_eq!(
sim.model().body_height(),
body_before - 4,
"the popup takes its rows from the conversation body"
);
let rendered = buffer_text(sim.capture_frame(80, 20), 80, 20);
assert!(
rendered.contains(&format!("3/{total} shown")),
"overflow counter missing: {rendered:?}"
);
for _ in 0..4 {
sim.inject_event(key(KeyCode::Down, Modifiers::empty()));
}
assert_eq!(sim.model().autocomplete.selected, Some(3));
let fourth = sim.model().autocomplete.items[3].label.clone();
let first = sim.model().autocomplete.items[0].label.clone();
let rendered = buffer_text(sim.capture_frame(80, 20), 80, 20);
assert!(
rendered.contains(&format!("▸ {fourth}")),
"scrolled window must show the highlight: {rendered:?}"
);
assert!(
!rendered.contains(&format!(" {first} ")),
"first row scrolled out of the window: {rendered:?}"
);
assert!(
rendered.contains(&format!("4/{total} shown")),
"counter follows the window: {rendered:?}"
);
}
#[test]
fn accepting_a_completion_replaces_only_the_command_token() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/he");
sim.inject_event(key(KeyCode::Left, Modifiers::empty()));
assert!(
sim.model().completion_visible(),
"cursor moves recompute the popup"
);
sim.inject_event(key(KeyCode::Tab, Modifiers::empty()));
assert_eq!(sim.model().input.text(), "/help");
assert_eq!(
sim.model().input.cursor().grapheme,
"/help".len(),
"cursor parks at the end of the accepted draft"
);
}
#[test]
fn layout_reserves_the_completion_rows_above_the_editor() {
let area = Rect::new(0, 0, 80, 20);
let regions = layout_regions(area, 1, 0, 4);
assert_eq!(regions.completion.height, 4);
assert_eq!(
regions.completion.y + regions.completion.height,
regions.input.y
);
assert_eq!(regions.status.y + 1, regions.completion.y);
let closed = layout_regions(area, 1, 0, 0);
assert_eq!(closed.completion.height, 0);
assert_eq!(closed.body.height, regions.body.height + 4);
}
#[test]
fn tool_card_transitions_and_bash_detail_folding() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::ToolStart {
name: "bash".into(),
tool_id: "t1".into(),
}));
assert!(
sim.model()
.transcript
.last()
.and_then(|e| e.card.as_ref())
.is_some_and(|c| *c == CardState::Pending),
"ToolStart must open a pending card"
);
let output = "line-one\nline-two";
sim.send(PiFtuiMsg::Agent(PiMsg::BashResult {
display: format!("$ demo\n{output}"),
content_for_agent: None,
}));
let card = sim
.model()
.transcript
.iter()
.rev()
.find(|e| e.text == "bash")
.expect("bash card exists");
assert!(
card.detail
.as_deref()
.is_some_and(|d| d.contains("line-one") && d.contains("line-two")),
"BashResult must fold its preview into the pending card"
);
sim.send(PiFtuiMsg::Agent(PiMsg::ToolEnd {
name: "bash".into(),
tool_id: "t1".into(),
is_error: false,
output: None,
}));
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.card == Some(CardState::Ok))
);
sim.send(PiFtuiMsg::Agent(PiMsg::ToolStart {
name: "edit".into(),
tool_id: "t2".into(),
}));
sim.send(PiFtuiMsg::Agent(PiMsg::ToolEnd {
name: "edit".into(),
tool_id: "t2".into(),
is_error: true,
output: None,
}));
assert!(
sim.model()
.transcript
.iter()
.any(|e| e.card == Some(CardState::Err))
);
let rendered = buffer_text(sim.capture_frame(60, 16), 60, 16);
assert!(
rendered.contains("✓ bash"),
"ok glyph missing: {rendered:?}"
);
assert!(
rendered.contains("✗ edit"),
"error glyph missing: {rendered:?}"
);
assert!(rendered.contains("line-one"), "folded detail missing");
}
#[test]
fn agent_end_provider_error_translates_to_structured_card() {
use crate::agent::AgentEvent as E;
use crate::model::{AssistantMessage, Message};
use std::sync::Arc;
let raw = "Provider error: deepseek: OpenAI API error (HTTP 503): \
{\"error\":{\"code\":\"service_unavailable_error\",\"message\":\"Server Overloaded\"}}";
let assistant = Arc::new(AssistantMessage {
provider: "deepseek".into(),
stop_reason: StopReason::Error,
error_message: Some(raw.into()),
..Default::default()
});
let msgs = agent_event_to_pi_msgs(&E::AgentEnd {
session_id: Arc::from("s1"),
messages: vec![Message::Assistant(Arc::clone(&assistant))],
error: Some(raw.into()),
});
let [
PiMsg::AgentDone {
stop_reason: StopReason::Error,
error_message: Some(card),
..
},
] = msgs.as_slice()
else {
panic!("unexpected translation: {msgs:?}");
};
let mut lines = card.lines();
assert_eq!(
lines.next(),
Some("Provider error: deepseek: HTTP 503 (service unavailable / overloaded)")
);
assert!(
lines
.next()
.is_some_and(|line| line.contains("not auto-retried")),
"retry status line missing: {card}"
);
assert!(
lines
.next()
.is_some_and(|line| line.starts_with("Detail: OpenAI API error (HTTP 503)")),
"detail line missing: {card}"
);
let summary = crate::error::ProviderErrorSummary::from_error_text(Some("deepseek"), raw);
assert!(
agent_event_to_pi_msgs(&E::ProviderError {
session_id: Arc::from("s1"),
provider: "deepseek".into(),
model: "deepseek-v4-pro".into(),
summary,
message: raw.into(),
})
.is_empty(),
"ProviderError must not double-post the card"
);
let aborted = Arc::new(AssistantMessage {
stop_reason: StopReason::Aborted,
error_message: Some("Aborted".into()),
..Default::default()
});
let msgs = agent_event_to_pi_msgs(&E::AgentEnd {
session_id: Arc::from("s1"),
messages: vec![Message::Assistant(aborted)],
error: Some("Aborted".into()),
});
assert!(
matches!(
msgs.as_slice(),
[PiMsg::AgentDone { error_message: Some(text), .. }] if text == "Aborted"
),
"abort must keep its plain message: {msgs:?}"
);
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, _submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::TextDelta(String::from("partial "))));
sim.send(PiFtuiMsg::Agent(PiMsg::AgentDone {
usage: None,
stop_reason: StopReason::Error,
error_message: Some(card.clone()),
}));
let transcript = &sim.model().transcript;
assert!(
transcript
.iter()
.any(|e| e.role == EntryRole::Assistant && e.text.starts_with("partial")),
"partial text must be kept"
);
assert!(
transcript.iter().any(|e| e.role == EntryRole::Error
&& e.text.starts_with("Provider error: deepseek: HTTP 503")),
"turn-end error card missing: {transcript:?}"
);
assert_eq!(sim.model().state, AgentUiState::Ready);
}
#[test]
fn agent_error_pins_banner_and_send_dismisses() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentError(String::from("boom"))));
assert_eq!(
sim.model().error_banner.as_deref(),
Some("boom"),
"AgentError pins the banner"
);
assert!(
!sim.model()
.transcript
.iter()
.any(|e| e.text.contains("boom"))
);
let rendered = buffer_text(sim.capture_frame(60, 12), 60, 12);
assert!(rendered.contains("✗ boom"), "banner missing: {rendered:?}");
type_str(&mut sim, "hi");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(sim.model().error_banner, None, "send must dismiss");
assert_eq!(
submit_rx.try_recv().expect("routed"),
UiCommand::Prompt(String::from("hi"))
);
}
#[test]
fn consecutive_reads_group_with_counter() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
for _ in 0..3 {
sim.send(PiFtuiMsg::Agent(PiMsg::ToolStart {
name: "read".into(),
tool_id: "t".into(),
}));
sim.send(PiFtuiMsg::Agent(PiMsg::ToolEnd {
name: "read".into(),
tool_id: "t".into(),
is_error: false,
output: None,
}));
}
let read_cards: Vec<_> = sim
.model()
.transcript
.iter()
.filter(|e| e.text == "read")
.collect();
assert_eq!(read_cards.len(), 1, "reads must collapse into one card");
assert_eq!(read_cards[0].group_count, 3);
sim.send(PiFtuiMsg::Agent(PiMsg::System(String::from("note"))));
sim.send(PiFtuiMsg::Agent(PiMsg::ToolStart {
name: "read".into(),
tool_id: "t2".into(),
}));
sim.send(PiFtuiMsg::Agent(PiMsg::ToolEnd {
name: "read".into(),
tool_id: "t2".into(),
is_error: false,
output: None,
}));
assert_eq!(
sim.model()
.transcript
.iter()
.filter(|e| e.text == "read")
.count(),
2,
"intervening entry must split the group"
);
let rendered = buffer_text(sim.capture_frame(60, 16), 60, 16);
assert!(
rendered.contains("×3"),
"group counter missing: {rendered:?}"
);
}
#[derive(Clone)]
struct NativePromptCall {
content: crate::model::UserContent,
thinking: Option<crate::model::ThinkingLevel>,
system_prompt: Option<String>,
}
#[derive(Default)]
struct NativePromptProbe {
calls: Mutex<Vec<NativePromptCall>>,
}
#[async_trait::async_trait]
#[allow(clippy::unnecessary_literal_bound)]
impl crate::provider::Provider for NativePromptProbe {
fn name(&self) -> &str {
"ftui-native-input"
}
fn api(&self) -> &str {
"test"
}
fn model_id(&self) -> &str {
"ftui-native-input"
}
async fn stream(
&self,
context: &crate::provider::Context<'_>,
options: &crate::provider::StreamOptions,
) -> crate::error::Result<
std::pin::Pin<
Box<
dyn futures::Stream<Item = crate::error::Result<crate::model::StreamEvent>>
+ Send,
>,
>,
> {
let content = context
.messages
.iter()
.rev()
.find_map(|message| match message {
crate::model::Message::User(user) => Some(user.content.clone()),
_ => None,
})
.expect("provider must receive a user input");
self.calls.lock().unwrap().push(NativePromptCall {
content,
thinking: options.thinking_level,
system_prompt: context.system_prompt.as_deref().map(str::to_string),
});
Ok(Box::pin(futures::stream::iter([Ok(
crate::model::StreamEvent::Done {
reason: StopReason::Stop,
message: crate::model::AssistantMessage {
content: vec![crate::model::ContentBlock::Text(
crate::model::TextContent::new("completed native input"),
)],
..Default::default()
},
},
)])))
}
}
fn native_prompt_handle(provider: Arc<NativePromptProbe>) -> crate::sdk::AgentSessionHandle {
let mut agent = crate::agent::Agent::new(
provider,
crate::tools::ToolRegistry::from_tools(Vec::new()),
crate::agent::AgentConfig {
stream_options: crate::provider::StreamOptions {
thinking_level: Some(crate::model::ThinkingLevel::Low),
..Default::default()
},
..Default::default()
},
);
agent.set_keyword_max_thinking_level(crate::model::ThinkingLevel::High);
let session = crate::agent::AgentSession::new(
agent,
Arc::new(asupersync::sync::Mutex::new(
crate::session::Session::in_memory(),
)),
false,
crate::compaction::ResolvedCompactionSettings::default(),
);
crate::sdk::AgentSessionHandle::from_session_with_listeners(
session,
crate::sdk::EventListeners::default(),
)
}
#[test]
#[allow(clippy::too_many_lines)]
fn capped_turn_returns_pending_input_and_reports_only_a_durable_pause() {
use crate::agent::AgentEvent;
use crate::model::{ContentBlock, Message, UserContent};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
let reactor = asupersync::runtime::reactor::create_reactor() .expect("reactor");
let runtime = asupersync::runtime::RuntimeBuilder::current_thread() .with_reactor(reactor)
.build()
.expect("runtime");
runtime.block_on(Box::pin(async {
for fail_save in [false, true] {
let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("capped-turn.jsonl");
let mut stored = crate::session::Session::in_memory();
stored.header.cwd = dir.path().display().to_string();
stored.path = Some(path.clone());
stored.session_dir = Some(dir.path().to_path_buf());
let owner_session_id = stored.header.id.clone();
let provider = Arc::new(NativePromptProbe::default());
let agent = crate::agent::Agent::new(
provider.clone(),
crate::tools::ToolRegistry::from_tools(Vec::new()),
crate::agent::AgentConfig {
max_time: Some(Duration::ZERO),
..Default::default()
},
);
let session = crate::agent::AgentSession::new(
agent,
Arc::new(asupersync::sync::Mutex::new(stored)),
true,
crate::compaction::ResolvedCompactionSettings {
enabled: false,
..Default::default()
},
);
let mut handle = crate::sdk::AgentSessionHandle::from_session_with_listeners(
session,
crate::sdk::EventListeners::default(),
);
let control: TurnControlSlot = Arc::new(Mutex::new(None));
let (tx, rx) = mpsc::channel();
send_conversation_reset(&handle, &tx, "cap regression").await;
let queued = Arc::new(AtomicBool::new(false));
let generated = Arc::new(AtomicUsize::new(0));
let queue_once = Arc::clone(&queued);
let generated_for_callback = Arc::clone(&generated);
let published_control = Arc::clone(&control);
let store = handle.session_store();
let invalid_save_path = dir.path().to_path_buf();
handle.subscribe(move |event| match event {
AgentEvent::AgentStart { .. } if !queue_once.swap(true, Ordering::SeqCst) => {
let current = published_control .lock()
.expect("control lock")
.clone()
.expect("active control");
current .follow_up("first unclaimed follow-up")
.expect("queue first follow-up");
current .follow_up("second unclaimed follow-up")
.expect("queue second follow-up");
}
AgentEvent::MessageEnd {
message: Message::Assistant(message),
} if message.content.iter().any(|block| {
matches!(
block,
ContentBlock::Text(text)
if text.text.starts_with("[time cap reached] ")
)
}) =>
{
generated_for_callback.fetch_add(1, Ordering::SeqCst);
if fail_save {
store.try_lock().expect("idle session store").path = Some(invalid_save_path.clone());
}
}
_ => {}
});
asupersync::time::timeout(
asupersync::time::wall_now(),
Duration::from_secs(10),
Box::pin(run_prompt_turn(
&mut handle,
"initial capped prompt".to_string(),
Vec::new(),
&tx,
&control,
)),
)
.await
.expect("capped driver watchdog");
assert!(queued.load(Ordering::SeqCst)); assert_eq!(
generated.load(Ordering::SeqCst),
1,
"pending work must not start another run with a fresh cap"
);
assert!(
provider.calls.lock().expect("provider capture").is_empty() );
assert!(
control.lock().expect("control lock").is_none() );
let events = rx.try_iter().collect::<Vec<_>>();
assert_eq!(
events
.iter()
.filter(|event| matches!(event, PiMsg::AgentStart))
.count(),
1
);
assert_eq!(
events
.iter()
.filter(|event| matches!(event, PiMsg::AgentDone { .. }))
.count(),
1
);
assert_eq!(
events
.iter()
.any(|event| matches!(event, PiMsg::AgentError(_))),
fail_save
);
let restored = events
.iter()
.filter_map(|event| match event {
PiMsg::RestorePendingInput {
owner_session_id,
text,
} => Some((owner_session_id.as_str(), text.as_str())),
_ => None,
})
.collect::<Vec<_>>();
let pending_text = "first unclaimed follow-up\n\nsecond unclaimed follow-up";
assert_eq!(
restored,
vec![(owner_session_id.as_str(), pending_text)]
);
let saved = crate::session::Session::open(&path.display().to_string()) .await
.expect("reopen original session");
let messages = saved.to_messages_for_current_path();
assert_eq!(
messages
.iter()
.filter(|message| matches!(message, Message::User(_)))
.count(),
1
);
assert!(
messages.iter().any(|message| matches!(
message,
Message::User(user)
if matches!(&user.content, UserContent::Text(text)
if text == "initial capped prompt")
))
);
let expected_markers = usize::from(!fail_save);
assert_eq!(
messages
.iter()
.filter(|message| matches!(
message,
Message::Assistant(assistant)
if crate::agent::time_cap_marker(assistant).is_some()
))
.count(),
expected_markers
);
let (_unused, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
for event in events {
if matches!(&event, PiMsg::RestorePendingInput { .. }) {
type_str(&mut sim, "new unsent draft");
}
sim.send(PiFtuiMsg::Agent(event));
}
assert_eq!(
sim.model().input.text(),
format!("{pending_text}\n\nnew unsent draft")
);
assert_eq!(
sim.model().state,
AgentUiState::Ready
);
assert_eq!(
sim.model()
.transcript
.iter()
.filter(|entry| { entry.text.starts_with("[time cap reached] ") })
.count(),
expected_markers,
"only a durably saved cap appears, exactly once"
);
}
}));
}
#[test]
fn provider_text_and_noncanonical_empty_identity_replies_do_not_report_a_pause() {
use crate::model::{AssistantMessage, ContentBlock, TextContent};
let marker = "[time cap reached] time cap reached after 0s (--max-time); stopping at the turn boundary";
let (tx, rx) = mpsc::channel();
report_turn_result(
Ok(AssistantMessage {
api: "openai-responses".to_string(),
provider: "openai".to_string(),
model: "fixture".to_string(),
content: vec![ContentBlock::Text(TextContent::new(marker))],
..Default::default()
}),
&tx,
);
for text in [
"[time cap reached] quoted documentation, not a runtime pause",
"[time cap reached] time cap reached after -1s (--max-time); stopping at the turn boundary",
"[time cap reached] time cap reached after 18446744073709551616s (--max-time); stopping at the turn boundary",
] {
report_turn_result(
Ok(AssistantMessage {
content: vec![ContentBlock::Text(TextContent::new(text))],
..Default::default()
}),
&tx,
);
}
report_turn_result(
Ok(AssistantMessage {
content: vec![
ContentBlock::Text(TextContent::new(marker)),
ContentBlock::Text(TextContent::new("additional provider content")),
],
..Default::default()
}),
&tx,
);
assert!(rx.try_recv().is_err()); }
#[test]
fn startup_commands_queue_plan_before_one_native_turn_and_remaining_prompts() {
let (tx, rx) = mpsc::channel();
let count = queue_initial_commands(
&tx,
true,
true,
vec!["first".into(), " \n ".into(), "/triage second".into()],
)
.expect("startup queue");
assert_eq!(count, 2);
assert_eq!(
rx.try_iter().collect::<Vec<_>>(),
vec![
UiCommand::Plan {
action: "enter".into(),
},
UiCommand::InitialPrompt,
UiCommand::Prompt("first".into()),
UiCommand::Prompt("/triage second".into()),
]
);
assert_eq!(
queue_initial_commands(&tx, false, false, Vec::new()).unwrap(),
0
);
assert!(
rx.try_recv().is_err(),
"an empty launch must not submit a turn"
);
}
#[test]
fn startup_extension_resolution_preserves_registration_and_raw_arguments() {
let commands = vec![crate::autocomplete::NamedEntry {
name: "Review".into(),
description: None,
}];
assert_eq!(
registered_prompt_command(&commands, " /review\t@private.wav keep spaces "),
Some(("Review".into(), "@private.wav keep spaces".into()))
);
for input in [
"/reviewer",
"review",
"/unregistered @private.wav",
"see /review",
] {
assert!(registered_prompt_command(&commands, input).is_none());
}
}
#[test]
fn startup_user_rows_require_current_session_and_keep_authored_history() {
let (_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel();
let mut sim = ProgramSimulator::new(PiFtuiModel::new(rx).with_submit_channel(submit_tx));
sim.init();
let submitted = |owner: &str| {
PiFtuiMsg::Agent(PiMsg::UserInputSubmitted {
owner_session_id: owner.to_string(),
text: "\x1b[31mvisible generated file body\x1b[0m".into(),
history_text: "authored prompt".into(),
})
};
sim.send(submitted("current"));
assert!(sim.model().input_history.is_empty());
assert!(sim.model().transcript.is_empty());
sim.send(PiFtuiMsg::Agent(PiMsg::ConversationReset {
session_id: "current".into(),
messages: Vec::new(),
usage: crate::model::Usage::default(),
status: None,
}));
sim.send(submitted("replaced"));
assert!(sim.model().input_history.is_empty());
assert!(sim.model().transcript.is_empty());
sim.send(submitted("current"));
assert_eq!(sim.model().transcript.len(), 1);
let user = &sim.model().transcript[0];
assert_eq!(user.role, EntryRole::User);
assert!(user.text.contains("visible generated file body"));
assert!(!user.text.contains('\x1b'));
assert_eq!(
sim.model().input_history,
vec!["authored prompt".to_string()]
);
assert!(
submit_rx.try_recv().is_err(),
"display must not enqueue another turn"
);
sim.send(PiFtuiMsg::Agent(PiMsg::ConversationReset {
session_id: "replacement".into(),
messages: Vec::new(),
usage: crate::model::Usage::default(),
status: None,
}));
sim.send(submitted("current"));
assert!(sim.model().transcript.is_empty());
}
#[test]
#[allow(clippy::significant_drop_tightening)]
fn typed_prompt_scans_prose_without_file_names_bodies_or_template_bytes() {
use crate::model::{ThinkingLevel, UserContent};
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("ultrathink.txt"), "attached workflowz").unwrap();
let mut resources = crate::resources::ResourceLoader::empty(true);
resources.push_prompt_for_tests(crate::resources::PromptTemplate {
name: "triage".into(),
description: String::new(),
content: "expanded template ultrathink".into(),
source: "user".into(),
file_path: dir.path().join("triage.md"),
});
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.unwrap();
runtime.block_on(async {
let provider = Arc::new(NativePromptProbe::default());
let mut handle = native_prompt_handle(provider.clone());
let control: TurnControlSlot = Arc::new(Mutex::new(None));
let (tx, rx) = mpsc::channel();
for prompt in ["/triage @ultrathink.txt orchestrate", "ultrathink"] {
run_typed_prompt(
&mut handle,
prompt.into(),
Some(&resources),
dir.path(),
false,
&tx,
&control,
)
.await;
}
let calls = provider.calls.lock().unwrap();
assert_eq!(calls.len(), 2);
let UserContent::Text(first) = &calls[0].content else {
panic!("text-only prompt must retain its text representation");
};
assert!(first.contains("attached workflowz"));
assert!(first.contains("expanded template ultrathink"));
assert_eq!(calls[0].thinking, Some(ThinkingLevel::Low));
let system = calls[0].system_prompt.as_deref().unwrap();
assert!(system.contains("invoked `orchestrate`"));
assert!(!system.contains("invoked `workflowz`"));
assert_eq!(calls[1].thinking, Some(ThinkingLevel::High));
assert!(matches!(&calls[1].content, UserContent::Text(text) if text == "ultrathink"));
assert!(
!rx.try_iter()
.any(|event| matches!(event, PiMsg::AgentError(_)))
);
});
}
#[test]
#[allow(clippy::too_many_lines)]
fn initial_native_prompt_keeps_prepared_bytes_order_and_authored_authority_once() {
use crate::model::{ContentBlock, TextContent, ThinkingLevel, UserContent};
use base64::Engine as _;
let dir = tempfile::tempdir().expect("tempdir");
let audio = b"RIFF\x00\xffWAVEfrozen-audio";
let video = b"\x00\x00\x00\x18ftypisom\xff\x80frozen-video";
let png_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMBAA7x2FoAAAAASUVORK5CYII=";
let png = base64::engine::general_purpose::STANDARD
.decode(png_data)
.unwrap();
for (name, bytes) in [
("clip.MP4", video.as_slice()),
("image.png", png.as_slice()),
("voice.WAV", audio.as_slice()),
(
"notes.txt",
b"frozen notes: ultrathink @mentioned.txt".as_slice(),
),
] {
std::fs::write(dir.path().join(name), bytes).unwrap();
}
std::fs::write(dir.path().join("mentioned.txt"), "must not be read again").unwrap();
let source = "/triage orchestrate";
let mut messages = vec![source.to_string(), "remaining CLI turn".to_string()];
let mut pending = crate::app::prepare_initial_message(
dir.path(),
&[
"clip.MP4".into(),
"image.png".into(),
"voice.WAV".into(),
"notes.txt".into(),
],
&mut messages,
false,
&crate::workspace::WorkspaceHandle::single(dir.path()),
)
.unwrap();
let prepared = pending.as_ref().expect("prepared initial turn");
let expanded = "Review the supplied files. workflowz";
let text = format!("{}{expanded}", prepared.text.strip_suffix(source).unwrap());
let mut expected = vec![ContentBlock::Text(TextContent::new(text))];
expected.extend_from_slice(&prepared.attachments);
assert!(matches!(
expected.as_slice(),
[
ContentBlock::Text(_),
ContentBlock::Media(_),
ContentBlock::Image(_),
ContentBlock::Media(_)
]
));
let expected = serde_json::to_value(UserContent::Blocks(expected)).unwrap();
for name in ["clip.MP4", "image.png", "voice.WAV", "notes.txt"] {
std::fs::write(dir.path().join(name), "changed after startup preparation").unwrap();
}
let mut resources = crate::resources::ResourceLoader::empty(true);
resources.push_prompt_for_tests(crate::resources::PromptTemplate {
name: "triage".into(),
description: String::new(),
content: expanded.into(),
source: "user".into(),
file_path: dir.path().join("triage.md"),
});
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.unwrap();
runtime.block_on(async {
let provider = Arc::new(NativePromptProbe::default());
let mut handle = native_prompt_handle(provider.clone());
let control: TurnControlSlot = Arc::new(Mutex::new(None));
let (tx, rx) = mpsc::channel();
send_conversation_reset(&handle, &tx, "startup").await;
run_initial_prompt(&mut handle, &mut pending, Some(&resources), &tx, &control).await;
run_initial_prompt(&mut handle, &mut pending, Some(&resources), &tx, &control).await;
assert!(pending.is_none());
assert!(control.lock().unwrap().is_none());
let calls = provider.calls.lock().unwrap();
assert_eq!(
calls.len(),
1,
"a duplicate startup marker must not replay input"
);
assert_eq!(serde_json::to_value(&calls[0].content).unwrap(), expected);
assert_eq!(calls[0].thinking, Some(ThinkingLevel::Low));
let system = calls[0]
.system_prompt
.as_deref()
.expect("authored directive");
assert!(system.contains("invoked `orchestrate`"));
assert!(!system.contains("invoked `workflowz`"));
drop(calls);
let events = rx.try_iter().collect::<Vec<_>>();
assert_eq!(
events
.iter()
.filter(|event| matches!(event, PiMsg::UserInputSubmitted { .. }))
.count(),
1
);
assert_eq!(
events
.iter()
.filter(|event| matches!(event, PiMsg::AgentStart))
.count(),
1
);
assert!(
!events
.iter()
.any(|event| matches!(event, PiMsg::AgentError(_)))
);
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
for event in events {
sim.send(PiFtuiMsg::Agent(event));
}
let users = sim
.model()
.transcript
.iter()
.filter(|entry| entry.role == EntryRole::User)
.collect::<Vec<_>>();
assert_eq!(
users.len(),
1,
"agent events must not duplicate the startup user row"
);
assert!(users[0].text.contains("frozen notes"));
assert!(users[0].text.contains("video/mp4"));
assert!(!users[0].text.contains(png_data));
assert_eq!(sim.model().input_history, vec![source.to_string()]);
assert_eq!(sim.model().state, AgentUiState::Ready);
});
assert_eq!(messages, vec!["remaining CLI turn".to_string()]);
}
#[test]
#[allow(clippy::significant_drop_tightening)]
fn retry_driver_preserves_interleaved_native_blocks_and_plain_text() {
use crate::model::{ContentBlock, ImageContent, MediaContent, TextContent, UserContent};
let blocks = vec![
ContentBlock::Media(MediaContent {
data: "AP+A/w==".into(),
mime_type: "audio/wav".into(),
name: Some("voice.wav".into()),
}),
ContentBlock::Text(TextContent::new("first text ultrathink")),
ContentBlock::Image(ImageContent {
data: "aW1hZ2U=".into(),
mime_type: "image/png".into(),
}),
ContentBlock::Text(TextContent::new("second text workflowz")),
ContentBlock::Media(MediaContent {
data: "AAECAw==".into(),
mime_type: "video/mp4".into(),
name: Some("clip.mp4".into()),
}),
];
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.unwrap();
runtime.block_on(async {
for content in [
UserContent::Blocks(blocks),
UserContent::Text("plain authored prompt".into()),
] {
let provider = Arc::new(NativePromptProbe::default());
let mut handle = native_prompt_handle(provider.clone());
let control: TurnControlSlot = Arc::new(Mutex::new(None));
let (tx, rx) = mpsc::channel();
let (text, attachments) = match &content {
UserContent::Text(text) => (text.clone(), Vec::new()),
UserContent::Blocks(blocks) => {
handle
.session_mut()
.agent
.set_magic_keyword_scan_override(Some(String::new()));
(String::new(), blocks.clone())
}
};
run_prompt_turn(&mut handle, text, attachments, &tx, &control).await;
run_retry_turn(&mut handle, &tx, &control).await;
assert!(control.lock().unwrap().is_none());
let calls = provider.calls.lock().unwrap();
assert_eq!(calls.len(), 2, "retry must start a real second turn");
let expected = serde_json::to_value(&content).unwrap();
for call in calls.iter() {
assert_eq!(serde_json::to_value(&call.content).unwrap(), expected);
assert_eq!(call.thinking, Some(crate::model::ThinkingLevel::Low));
assert!(call.system_prompt.as_deref().is_none_or(str::is_empty));
}
let events = rx.try_iter().collect::<Vec<_>>();
assert!(
!events
.iter()
.any(|event| matches!(event, PiMsg::AgentError(_)))
);
let text = events
.iter()
.find_map(|event| match event {
PiMsg::RetryCommitted { text, .. } => Some(text.as_str()),
_ => None,
})
.expect("retry transcript update");
match &content {
UserContent::Blocks(_) => {
assert!(text.find("voice.wav").unwrap() < text.find("first text").unwrap());
assert!(
text.find("image/png").unwrap() < text.find("second text").unwrap()
);
assert!(text.ends_with("[Media: clip.mp4, video/mp4, 4 B]"));
for payload in ["AP+A/w==", "aW1hZ2U=", "AAECAw=="] {
assert!(!text.contains(payload));
}
}
UserContent::Text(expected) => assert_eq!(text, expected),
}
}
});
}
#[test]
fn prepare_prompt_reads_file_references_and_attaches_images() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("notes.txt"), "remember the milk\n").expect("write");
let png: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78,
0x9C, 0x63, 0xF8, 0xCF, 0xC0, 0xF0, 0x1F, 0x00, 0x05, 0x00, 0x01, 0xFF, 0x89, 0x99,
0x3D, 0x1D, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
];
std::fs::write(dir.path().join("shot.png"), png).expect("write png");
let (text, images) = prepare_prompt(
"summarize @notes.txt and look at @shot.png please",
None,
dir.path(),
None,
false,
)
.expect("prepared");
assert!(text.contains("remember the milk"), "{text}");
assert!(text.contains("summarize and look at please"), "{text}");
assert!(!text.contains("@notes.txt"), "{text}");
assert_eq!(images.len(), 1, "the image is attached");
let (plain, none) =
prepare_prompt("no refs, @missing.txt stays", None, dir.path(), None, false)
.expect("prepared");
assert_eq!(plain, "no refs, @missing.txt stays");
assert!(none.is_empty());
}
#[test]
fn prepare_prompt_expands_a_template_and_reads_its_file_argument() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("bug.txt"), "stack trace here\n").expect("write");
let mut resources = crate::resources::ResourceLoader::empty(true);
resources.push_prompt_for_tests(crate::resources::PromptTemplate {
name: "triage".to_string(),
description: String::new(),
content: "Triage the attached report.".to_string(),
source: "user".to_string(),
file_path: std::path::PathBuf::from("/tmp/triage.md"),
});
let (text, images) = prepare_prompt(
"/triage @bug.txt",
Some(&resources),
dir.path(),
None,
false,
)
.expect("prepared");
assert!(text.contains("stack trace here"), "{text}");
assert!(text.contains("Triage the attached report."), "{text}");
assert!(images.is_empty());
}
#[test]
fn scope_models_matches_globs_and_exact_ids() {
let available: Vec<String> = [
"openai/gpt-4o",
"openai/gpt-5",
"anthropic/claude-sonnet-4-5",
"google/gemini-2.5-pro",
]
.into_iter()
.map(String::from)
.collect();
let pats = |p: &[&str]| p.iter().map(|s| (*s).to_string()).collect::<Vec<_>>();
assert_eq!(
scope_models(&pats(&["gpt-*"]), &available).unwrap(),
vec!["openai/gpt-4o", "openai/gpt-5"]
);
assert_eq!(
scope_models(
&pats(&["claude-sonnet-4-5:high", "google/gemini-2.5-pro"]),
&available
)
.unwrap(),
vec!["anthropic/claude-sonnet-4-5", "google/gemini-2.5-pro"]
);
assert!(
scope_models(&pats(&["nothing-like-it"]), &available)
.unwrap()
.is_empty()
);
assert!(scope_models(&pats(&["[bad"]), &available).is_err());
}
#[test]
fn scoped_models_command_sets_the_cycle_and_saves_the_patterns() {
let dir = tempfile::tempdir().expect("tempdir");
let available = vec![
String::from("openai/gpt-5"),
String::from("anthropic/claude-x"),
];
let mut cycle = available.clone();
let reply = run_scoped_models_command("gpt-*", &available, &mut cycle, dir.path());
assert!(matches!(reply, PiMsg::System(ref text) if text.contains("1 model(s)")));
assert_eq!(cycle, vec![String::from("openai/gpt-5")]);
let saved = std::fs::read_to_string(dir.path().join(".pi").join("settings.json"))
.expect("project settings written");
assert!(saved.contains("gpt-*"), "{saved}");
let unchanged = run_scoped_models_command("zzz-*", &available, &mut cycle, dir.path());
assert!(matches!(unchanged, PiMsg::System(ref text) if text.contains("unchanged")));
assert_eq!(cycle, vec![String::from("openai/gpt-5")]);
run_scoped_models_command("clear", &available, &mut cycle, dir.path());
assert_eq!(cycle, available);
}
#[test]
fn last_url_finds_the_newest_link_without_trailing_punctuation() {
let (_tx, mut model) = new_model();
assert_eq!(last_url(&model.transcript), None);
model.push_entry(
EntryRole::Assistant,
String::from("Docs are at https://example.com/old."),
);
model.push_entry(
EntryRole::Assistant,
String::from("See (https://example.com/new?q=1), then rerun."),
);
model.push_entry(EntryRole::System, String::from("no link here"));
assert_eq!(
last_url(&model.transcript).as_deref(),
Some("https://example.com/new?q=1")
);
}
#[test]
fn up_and_down_recall_sent_prompts_and_restore_the_draft() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, _submit_rx) = mpsc::channel::<UiCommand>();
let mut sim = ProgramSimulator::new(PiFtuiModel::new(rx).with_submit_channel(submit_tx));
sim.init();
for prompt in ["first prompt", "second prompt"] {
type_str(&mut sim, prompt);
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
sim.model_mut().state = AgentUiState::Ready;
}
type_str(&mut sim, "half a draft");
let up = || key(KeyCode::Up, Modifiers::empty());
let down = || key(KeyCode::Down, Modifiers::empty());
sim.inject_event(up());
assert_eq!(sim.model().input.text(), "second prompt");
sim.inject_event(up());
assert_eq!(sim.model().input.text(), "first prompt");
sim.inject_event(up());
assert_eq!(
sim.model().input.text(),
"first prompt",
"stays on the oldest"
);
sim.inject_event(down());
assert_eq!(sim.model().input.text(), "second prompt");
sim.inject_event(down());
assert_eq!(sim.model().input.text(), "half a draft");
sim.model_mut().input.set_text("");
type_str(&mut sim, "/history");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
let listing = &sim.model().transcript.last().expect("listing").text;
assert!(
listing.contains("2. second prompt") && listing.contains("3. first prompt"),
"{listing}"
);
}
#[test]
fn up_in_a_multiline_draft_does_not_recall() {
let (_tx, mut model) = new_model();
model.record_history("an old prompt");
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.model_mut().input.set_text("line one\nline two");
sim.inject_event(key(KeyCode::Up, Modifiers::empty()));
assert_eq!(sim.model().input.text(), "line one\nline two");
}
#[test]
fn recall_walks_through_a_multiline_entry() {
let (_tx, mut model) = new_model();
model.record_history("oldest");
model.record_history("two\nlines");
model.record_history("newest");
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "draft");
let up = || key(KeyCode::Up, Modifiers::empty());
let down = || key(KeyCode::Down, Modifiers::empty());
sim.inject_event(up());
sim.inject_event(up());
assert_eq!(sim.model().input.text(), "two\nlines");
sim.inject_event(up());
assert_eq!(sim.model().input.text(), "oldest");
sim.inject_event(down());
sim.inject_event(down());
sim.inject_event(down());
assert_eq!(sim.model().input.text(), "draft");
}
#[test]
fn turn_entries_keep_their_order_around_a_tool_call() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::ThinkingDelta("look first".into())));
sim.send(PiFtuiMsg::Agent(PiMsg::TextDelta("Let me look.".into())));
sim.send(PiFtuiMsg::Agent(PiMsg::ToolStart {
name: "read".into(),
tool_id: "r1".into(),
}));
sim.send(PiFtuiMsg::Agent(PiMsg::ToolEnd {
name: "read".into(),
tool_id: "r1".into(),
is_error: false,
output: None,
}));
sim.send(PiFtuiMsg::Agent(PiMsg::TextDelta("Found it.".into())));
sim.send(PiFtuiMsg::Agent(PiMsg::AgentDone {
usage: None,
stop_reason: StopReason::Stop,
error_message: None,
}));
let order: Vec<(EntryRole, &str)> = sim
.model()
.transcript
.iter()
.map(|e| (e.role, e.text.as_str()))
.collect();
assert_eq!(
order,
vec![
(EntryRole::Thinking, "look first"),
(EntryRole::Assistant, "Let me look."),
(EntryRole::System, "read"),
(EntryRole::Assistant, "Found it."),
]
);
}
#[test]
fn ctrl_t_shows_and_hides_thinking() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::ThinkingDelta(
"weigh option alpha\nweigh option beta".into(),
)));
sim.send(PiFtuiMsg::Agent(PiMsg::TextDelta("beta.".into())));
sim.send(PiFtuiMsg::Agent(PiMsg::AgentDone {
usage: None,
stop_reason: StopReason::Stop,
error_message: None,
}));
let hidden = buffer_text(sim.capture_frame(80, 24), 80, 24);
assert!(hidden.contains("thinking · 2 lines (ctrl+t"), "{hidden}");
assert!(!hidden.contains("option beta"), "{hidden}");
sim.inject_event(key(KeyCode::Char('t'), Modifiers::CTRL));
let shown = buffer_text(sim.capture_frame(80, 24), 80, 24);
assert!(shown.contains("weigh option beta"), "{shown}");
sim.inject_event(key(KeyCode::Char('t'), Modifiers::CTRL));
let again = buffer_text(sim.capture_frame(80, 24), 80, 24);
assert!(!again.contains("option beta"), "{again}");
}
#[test]
fn thinking_starts_visible_when_the_setting_allows() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model.with_thinking_visible(true));
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::ThinkingDelta(
"weigh option alpha\nweigh option beta".into(),
)));
let live = buffer_text(sim.capture_frame(80, 24), 80, 24);
assert!(live.contains("weigh option beta"), "{live}");
sim.send(PiFtuiMsg::Agent(PiMsg::TextDelta("beta.".into())));
sim.send(PiFtuiMsg::Agent(PiMsg::AgentDone {
usage: None,
stop_reason: StopReason::Stop,
error_message: None,
}));
let shown = buffer_text(sim.capture_frame(80, 24), 80, 24);
assert!(shown.contains("weigh option beta"), "{shown}");
sim.inject_event(key(KeyCode::Char('t'), Modifiers::CTRL));
let hidden = buffer_text(sim.capture_frame(80, 24), 80, 24);
assert!(!hidden.contains("option beta"), "{hidden}");
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::ThinkingDelta("one\ntwo".into())));
let live = buffer_text(sim.capture_frame(80, 24), 80, 24);
assert!(live.contains("thinking · 2 lines (ctrl+t"), "{live}");
assert!(!live.contains("two"), "{live}");
}
#[test]
fn prompt_templates_expand_unless_an_extension_claims_the_name() {
let mut resources = crate::resources::ResourceLoader::empty(true);
resources.push_prompt_for_tests(crate::resources::PromptTemplate {
name: "fix".to_string(),
description: "Fix a bug".to_string(),
content: "Find and fix the bug in $1".to_string(),
source: "user".to_string(),
file_path: std::path::PathBuf::from("/tmp/fix.md"),
});
assert_eq!(
template_prompt(Some(&resources), false, "fix", "src/parser.rs").as_deref(),
Some("Find and fix the bug in src/parser.rs")
);
assert_eq!(template_prompt(Some(&resources), true, "fix", "x"), None);
assert_eq!(template_prompt(Some(&resources), false, "nope", ""), None);
assert_eq!(template_prompt(None, false, "fix", ""), None);
}
#[test]
fn reload_picks_up_a_prompt_template_added_after_launch() {
let dir = tempfile::tempdir().expect("tempdir");
let mut source = ResourceSource {
package_manager: crate::package_manager::PackageManager::new(dir.path().to_path_buf()),
config: crate::config::Config::default(),
cli: crate::resources::ResourceCliOptions {
no_skills: true,
no_prompt_templates: false,
no_extensions: true,
no_themes: true,
skill_paths: Vec::new(),
prompt_paths: Vec::new(),
extension_paths: Vec::new(),
theme_paths: Vec::new(),
},
};
let prompts = dir.path().join(".pi").join("prompts");
std::fs::create_dir_all(&prompts).expect("prompts dir");
std::fs::write(
prompts.join("triage.md"),
"---\ndescription: Triage an issue\n---\nTriage issue $1",
)
.expect("write template");
let (tx, rx) = mpsc::channel();
let mut resources = None;
let mut catalog = AutocompleteCatalog::default();
let ext = vec![crate::autocomplete::NamedEntry {
name: String::from("ext-cmd"),
description: None,
}];
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.build()
.expect("runtime");
runtime.block_on(reload_driver_resources(
&source,
dir.path(),
ext,
&mut resources,
&mut catalog,
&tx,
));
assert_eq!(
template_prompt(resources.as_ref(), false, "triage", "#12").as_deref(),
Some("Triage issue #12")
);
assert!(catalog.prompt_templates.iter().any(|t| t.name == "triage"));
let Ok(PiMsg::AutocompleteCatalog(sent)) = rx.try_recv() else {
panic!("the refreshed catalog goes to the UI");
};
assert!(sent.prompt_templates.iter().any(|t| t.name == "triage"));
assert!(sent.extension_commands.iter().any(|c| c.name == "ext-cmd"));
source.cli.no_prompt_templates = true;
source.cli.prompt_paths = vec![String::new()];
runtime.block_on(reload_driver_resources(
&source,
dir.path(),
Vec::new(),
&mut resources,
&mut catalog,
&tx,
));
let replies = rx.try_iter().collect::<Vec<_>>();
assert!(replies.iter().any(|message| {
matches!(message, PiMsg::System(text) if text.contains("were not re-read"))
}));
assert!(replies.iter().any(|message| {
matches!(message, PiMsg::AutocompleteCatalog(updated)
if updated.extension_commands.is_empty()
&& updated.prompt_templates.iter().any(|entry| entry.name == "triage"))
}));
assert!(catalog.extension_commands.is_empty());
assert_eq!(
template_prompt(resources.as_ref(), false, "triage", "#13").as_deref(),
Some("Triage issue #13")
);
}
#[test]
fn ctrl_o_expands_and_collapses_tool_output() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
let output = (1..=20)
.map(|n| format!("row{n:02}"))
.collect::<Vec<_>>()
.join("\n");
sim.send(PiFtuiMsg::Agent(PiMsg::ToolStart {
name: "grep".into(),
tool_id: "g1".into(),
}));
sim.send(PiFtuiMsg::Agent(PiMsg::ToolEnd {
name: "grep".into(),
tool_id: "g1".into(),
is_error: false,
output: Some(output),
}));
let collapsed = buffer_text(sim.capture_frame(80, 40), 80, 40);
assert!(collapsed.contains("row08"), "{collapsed}");
assert!(!collapsed.contains("row09"), "{collapsed}");
assert!(collapsed.contains("+12 more lines (ctrl+o"), "{collapsed}");
sim.inject_event(key(KeyCode::Char('o'), Modifiers::CTRL));
let expanded = buffer_text(sim.capture_frame(80, 40), 80, 40);
assert!(expanded.contains("row20"), "{expanded}");
assert!(!expanded.contains("ctrl+o to expand"), "{expanded}");
sim.inject_event(key(KeyCode::Char('o'), Modifiers::CTRL));
let again = buffer_text(sim.capture_frame(80, 40), 80, 40);
assert!(!again.contains("row20"), "{again}");
}
#[test]
fn collapse_detail_counts_lines_the_source_already_elided() {
let mut body: Vec<&str> = vec!["x"; 10];
body.push("… +300 more lines");
let (shown, hidden) = collapse_detail(&body);
assert_eq!(shown.len(), COLLAPSED_DETAIL_LINES);
assert_eq!(hidden, 2 + 300);
let (shown, hidden) = collapse_detail(&["a", "b"]);
assert_eq!((shown.len(), hidden), (2, 0));
}
#[test]
fn word_diff_parts_pairs_shared_framing() {
let (prefix, removed_mid, added_mid, suffix) =
word_diff_parts("foo bar baz", "foo qux baz").expect("paired");
assert_eq!(prefix, "foo ");
assert_eq!(removed_mid, "bar");
assert_eq!(added_mid, "qux");
assert_eq!(suffix, " baz");
}
#[test]
fn word_diff_parts_rejects_unframed_and_identical() {
assert!(word_diff_parts("same line", "same line").is_none());
assert!(word_diff_parts("alpha beta", "gamma delta").is_none());
let (prefix, removed_mid, added_mid, suffix) =
word_diff_parts("keep a", "keep b").expect("paired");
assert_eq!(prefix, "keep ");
assert_eq!(
(removed_mid.as_str(), added_mid.as_str(), suffix.as_str()),
("a", "b", "")
);
}
fn finish_turn(sim: &mut ProgramSimulator<PiFtuiModel>, text: &str) {
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::TextDelta(text.into())));
sim.send(PiFtuiMsg::Agent(PiMsg::AgentDone {
usage: None,
stop_reason: StopReason::Stop,
error_message: None,
}));
}
#[test]
fn render_cache_makes_warm_frames_o_changed_not_o_transcript() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
for i in 0..4 {
finish_turn(&mut sim, &format!("message **{i}** body"));
}
assert_eq!(sim.model().transcript.len(), 4);
let _ = sim.model().conversation_text(TEST_BODY_WIDTH);
assert_eq!(
sim.model().render_stats.get(),
(4, 0),
"cold frame renders every block"
);
let _ = sim.model().conversation_text(TEST_BODY_WIDTH);
assert_eq!(
sim.model().render_stats.get(),
(0, 4),
"warm frame must reuse every unchanged block"
);
finish_turn(&mut sim, "one more");
let _ = sim.model().conversation_text(TEST_BODY_WIDTH);
assert_eq!(
sim.model().render_stats.get(),
(1, 4),
"only the new entry may render"
);
let _ = sim.model().conversation_text(TEST_BODY_WIDTH);
assert_eq!(sim.model().render_stats.get(), (0, 5));
}
#[test]
fn render_cache_skips_pending_cards_and_settles_finished_ones() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
finish_turn(&mut sim, "before the tool");
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::ToolStart {
name: "bash".into(),
tool_id: "t1".into(),
}));
let _ = sim.model().conversation_text(TEST_BODY_WIDTH);
let _ = sim.model().conversation_text(TEST_BODY_WIDTH);
assert_eq!(
sim.model().render_stats.get(),
(1, 1),
"a pending card renders fresh every frame (spinner-dependent)"
);
sim.send(PiFtuiMsg::Agent(PiMsg::ToolEnd {
name: "bash".into(),
tool_id: "t1".into(),
is_error: false,
output: Some("ok".into()),
}));
sim.send(PiFtuiMsg::Agent(PiMsg::AgentDone {
usage: None,
stop_reason: StopReason::Stop,
error_message: None,
}));
let _ = sim.model().conversation_text(TEST_BODY_WIDTH);
let (rendered, _) = sim.model().render_stats.get();
assert!(
rendered >= 1,
"the settled card must re-render once after mutation"
);
let _ = sim.model().conversation_text(TEST_BODY_WIDTH);
let (rendered, reused) = sim.model().render_stats.get();
assert_eq!(rendered, 0, "settled card caches like any other block");
assert_eq!(reused, sim.model().transcript.len());
}
#[test]
fn render_cache_leaves_streaming_tail_uncached() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.send(PiFtuiMsg::Agent(PiMsg::AgentStart));
sim.send(PiFtuiMsg::Agent(PiMsg::TextDelta("streaming tail".into())));
let text = sim.model().conversation_text(TEST_BODY_WIDTH);
assert!(
text.lines()
.iter()
.any(|line| line.to_plain_text().contains("streaming tail")),
"streaming tail must render"
);
assert_eq!(
sim.model().render_stats.get(),
(0, 0),
"the in-flight tail is not a cached block"
);
}
#[test]
fn render_cache_flushes_on_theme_change() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
finish_turn(&mut sim, "themed message");
let _ = sim.model().conversation_text(TEST_BODY_WIDTH);
let _ = sim.model().conversation_text(TEST_BODY_WIDTH);
assert_eq!(sim.model().render_stats.get(), (0, 1));
type_str(&mut sim, "/theme");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
sim.inject_event(key(KeyCode::Enter, Modifiers::empty())); let _ = sim.model().conversation_text(TEST_BODY_WIDTH);
let (rendered, reused) = sim.model().render_stats.get();
assert_eq!(reused, 0, "theme change must drop every cached block");
assert_eq!(rendered, sim.model().transcript.len());
}
#[test]
fn markdown_tables_fit_the_terminal_width_and_refit_on_resize() {
let source = "| Column one with a long header | Column two with a longer header | Column three |\n\
|---|---|---|\n\
| first cell has quite a lot of text in it | second cell also has a lot of text | third |\n\
| short | short | short |";
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
sim.inject_event(Event::Resize {
width: 48,
height: 24,
});
finish_turn(&mut sim, source);
let widest = |model: &PiFtuiModel, width: u16| {
model
.conversation_text(width)
.lines()
.iter()
.map(|line| line.to_plain_text().chars().count())
.max()
.unwrap_or(0)
};
let narrow = widest(sim.model(), 48);
assert!(
narrow <= 48,
"table must be fitted to a 48-column terminal, widest rendered line is {narrow}"
);
sim.inject_event(Event::Resize {
width: 120,
height: 24,
});
let _ = sim.model().conversation_text(120);
let (rendered, reused) = sim.model().render_stats.get();
assert_eq!(reused, 0, "width change must drop every cached block");
assert_eq!(rendered, sim.model().transcript.len());
let wide = widest(sim.model(), 120);
assert!(
wide > narrow,
"a wider terminal must let the table use more width (narrow={narrow}, wide={wide})"
);
let _ = sim.model().conversation_text(120);
sim.inject_event(Event::Resize {
width: 120,
height: 40,
});
let _ = sim.model().conversation_text(120);
let (_, reused) = sim.model().render_stats.get();
assert!(
reused > 0,
"height-only resize must not flush the render cache"
);
}
#[test]
fn compact_spacing_collapses_paragraph_gaps_keeps_heading_and_fence_air() {
let source = "para one\n\npara two\n\n# Section\n\nbody text\n\n```rust\nlet x = 1;\n```\n\ntail line";
let render = |spacing: crate::config::MarkdownSpacing| {
let (_tx, rx) = mpsc::channel();
let model = PiFtuiModel::new(rx).with_markdown_spacing(spacing);
let mut sim = ProgramSimulator::new(model);
sim.init();
finish_turn(&mut sim, source);
sim.model()
.conversation_text(TEST_BODY_WIDTH)
.lines()
.iter()
.map(ftui::text::Line::to_plain_text)
.collect::<Vec<_>>()
};
let comfortable = render(crate::config::MarkdownSpacing::Comfortable);
let compact = render(crate::config::MarkdownSpacing::Compact);
assert!(
compact.len() < comfortable.len(),
"compact must be denser: comfortable={comfortable:?} compact={compact:?}"
);
let para = compact
.iter()
.position(|l| l.contains("para one"))
.expect("para one rendered");
assert!(
compact[para + 1].contains("para two"),
"paragraph gap must collapse: {compact:?}"
);
let head = compact
.iter()
.position(|l| l.contains("Section"))
.expect("heading rendered");
assert!(
compact[head - 1].trim().is_empty(),
"heading keeps a blank above: {compact:?}"
);
assert!(
compact[head + 1].trim().is_empty(),
"heading keeps a blank below: {compact:?}"
);
let code = compact
.iter()
.position(|l| l.contains("let x = 1;"))
.expect("code line rendered");
assert!(
compact[code].starts_with(" "),
"code body keeps its indent: {compact:?}"
);
let tail = compact
.iter()
.position(|l| l.contains("tail line"))
.expect("tail rendered");
assert!(
compact[tail - 1].trim().is_empty(),
"fence keeps a blank below: {compact:?}"
);
}
#[test]
fn comfortable_spacing_is_the_unchanged_default() {
let (_tx, model) = new_model();
assert_eq!(
model.markdown_spacing,
crate::config::MarkdownSpacing::Comfortable
);
}
#[test]
fn model_switch_arms_busy_spinner_until_driver_replies() {
use ftui::runtime::simulator::CmdRecord;
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, _submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/model openai/gpt-5");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
sim.model().busy_label(),
Some("switching model to openai/gpt-5 ..."),
"routing /model must arm the busy indicator"
);
assert!(
matches!(sim.command_log().last(), Some(CmdRecord::Tick(_))),
"arming busy must start the spinner tick chain"
);
let before = sim.model().spinner.current_frame;
sim.inject_event(Event::Tick);
assert_eq!(sim.model().spinner.current_frame, before + 1);
assert!(matches!(sim.command_log().last(), Some(CmdRecord::Tick(_))));
let spin = DOTS[sim.model().spinner.current_frame % DOTS.len()];
let rendered = buffer_text(sim.capture_frame(50, 10), 50, 10);
assert!(
rendered.contains(&format!("{spin} switching model to")),
"status region missing busy spinner: {rendered:?}"
);
sim.send(PiFtuiMsg::Agent(PiMsg::System(
"model set to openai/gpt-5".into(),
)));
assert!(sim.model().busy.is_none(), "driver reply must clear busy");
let frame = sim.model().spinner.current_frame;
sim.inject_event(Event::Tick);
assert_eq!(sim.model().spinner.current_frame, frame);
assert!(matches!(sim.command_log().last(), Some(CmdRecord::None)));
}
#[test]
fn resume_picker_selection_arms_busy_indicator() {
use ftui::runtime::simulator::CmdRecord;
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx)
.with_submit_channel(submit_tx)
.with_available_sessions(vec![("old session · 3 msgs".into(), "/tmp/s.jsonl".into())]);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/resume");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert!(sim.model().picker.is_some(), "picker must open");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(
submit_rx.try_recv().expect("resume routed"),
UiCommand::ResumeSession {
path: "/tmp/s.jsonl".into()
}
);
assert_eq!(sim.model().busy_label(), Some("loading session ..."));
assert!(
matches!(sim.command_log().last(), Some(CmdRecord::Tick(_))),
"picker selection must start the spinner tick chain"
);
}
#[test]
fn extension_command_busy_survives_its_own_tool_card_until_it_ends() {
let (_agent_tx, rx) = mpsc::channel();
let (submit_tx, _submit_rx) = mpsc::channel::<UiCommand>();
let model = PiFtuiModel::new(rx).with_submit_channel(submit_tx);
let mut sim = ProgramSimulator::new(model);
sim.init();
type_str(&mut sim, "/mycmd arg");
sim.inject_event(key(KeyCode::Enter, Modifiers::empty()));
assert_eq!(sim.model().busy_label(), Some("running /mycmd ..."));
sim.send(PiFtuiMsg::Agent(PiMsg::ToolStart {
name: "/mycmd".into(),
tool_id: "ftui-ext-command".into(),
}));
assert!(
sim.model().busy.is_some(),
"ToolStart must not clear the busy label"
);
sim.send(PiFtuiMsg::Agent(PiMsg::ToolEnd {
name: "/mycmd".into(),
tool_id: "ftui-ext-command".into(),
is_error: false,
output: None,
}));
assert!(sim.model().busy.is_none(), "ToolEnd settles the busy op");
}
const WRAP_WIDTHS: [u16; 5] = [40, 80, 120, 200, 240];
const WRAP_TAIL: &str = "ENDMARK";
fn wrap_probe_paragraph() -> String {
format!(
"The sky appears blue because of a phenomenon called Rayleigh scattering, \
in which the shorter wavelengths of visible light are scattered far more \
strongly by the molecules of the atmosphere than the longer red \
wavelengths are, which is also the reason sunsets turn red once the light \
has to travel a much greater distance through the air {WRAP_TAIL}"
)
}
fn plain_lines(text: &Text<'static>) -> Vec<String> {
text.lines()
.iter()
.map(ftui::text::Line::to_plain_text)
.collect()
}
#[test]
fn long_answer_lines_wrap_at_every_terminal_width() {
for width in WRAP_WIDTHS {
let (_tx, mut model) = new_model();
model.push_entry(EntryRole::Assistant, wrap_probe_paragraph());
let mut sim = ProgramSimulator::new(model);
sim.init();
let height = 40_u16;
let rendered = buffer_text(sim.capture_frame(width, height), width, height);
assert!(
rendered.contains(WRAP_TAIL),
"width {width}: answer clipped instead of wrapped:\n{rendered}"
);
}
}
#[test]
fn wrapped_body_lines_fit_the_width_and_keep_every_word() {
let source = wrap_probe_paragraph();
let expected: Vec<&str> = source.split_whitespace().collect();
for width in WRAP_WIDTHS {
let (_tx, mut model) = new_model();
model.push_entry(EntryRole::Assistant, source.clone());
let text = model.conversation_text(width);
for line in text.lines() {
assert!(
line.width() <= usize::from(width),
"width {width}: line overflows by {} cells: {:?}",
line.width() - usize::from(width),
line.to_plain_text()
);
}
let words: Vec<String> = plain_lines(&text)
.join(" ")
.split_whitespace()
.map(str::to_owned)
.collect();
assert_eq!(
words, expected,
"width {width}: wrapping altered the answer text"
);
}
}
#[test]
fn unbreakable_tokens_hard_break_instead_of_overflowing() {
let url = format!(
"https://example.com/{}/end",
"segment-with-no-spaces-at-all".repeat(6)
);
for width in [20_u16, 40, 80] {
let (_tx, mut model) = new_model();
model.push_entry(EntryRole::Assistant, url.clone());
let text = model.conversation_text(width);
for line in text.lines() {
assert!(
line.width() <= usize::from(width),
"width {width}: unbroken token overflowed: {:?}",
line.to_plain_text()
);
}
let joined: String = plain_lines(&text)
.iter()
.flat_map(|line| line.chars())
.filter(|c| !c.is_whitespace())
.collect();
let want: String = url.chars().filter(|c| !c.is_whitespace()).collect();
assert!(
joined.contains(&want),
"width {width}: hard break lost characters: {joined:?}"
);
}
}
#[test]
fn wide_characters_wrap_by_display_cells() {
let cjk = "天空之所以是蓝色的是因为瑞利散射现象".repeat(8);
for width in [10_u16, 40, 80, 240] {
let (_tx, mut model) = new_model();
model.push_entry(EntryRole::Assistant, cjk.clone());
let text = model.conversation_text(width);
for line in text.lines() {
assert!(
line.width() <= usize::from(width),
"width {width}: wide-char line overflowed: {:?}",
line.to_plain_text()
);
}
}
}
#[test]
fn wrapped_list_items_hang_under_their_marker() {
let source = format!(
"- {} {WRAP_TAIL}",
"a list item whose body runs well past the frame ".repeat(3)
);
let (_tx, mut model) = new_model();
model.push_entry(EntryRole::Assistant, source);
let lines = plain_lines(&model.conversation_text(40));
let start = lines
.iter()
.position(|line| line.contains("a list item"))
.expect("list item rendered");
let tail = lines
.iter()
.position(|line| line.contains(WRAP_TAIL))
.expect("list item tail rendered");
assert!(tail > start, "the list item must have wrapped: {lines:?}");
let text_at = lines[start]
.find("a list item")
.expect("marker precedes the text");
let marker_cells = display_width(&lines[start][..text_at]);
assert!(
marker_cells > 0,
"markdown emits a bullet marker: {lines:?}"
);
for line in &lines[start + 1..=tail] {
assert!(
line.starts_with(&" ".repeat(marker_cells)),
"continuation lost the hanging indent: {line:?} in {lines:?}"
);
assert!(
!line.trim_start().starts_with('•'),
"the bullet must not repeat on continuations: {line:?}"
);
}
}
#[test]
fn wrapped_code_block_rows_keep_their_indent() {
let long = format!("let value = \"{}\"; // {WRAP_TAIL}", "x".repeat(30));
let source = format!("```rust\n{long}\n```");
let (_tx, mut model) = new_model();
model.push_entry(EntryRole::Assistant, source);
let lines = plain_lines(&model.conversation_text(40));
let start = lines
.iter()
.position(|line| line.contains("let value"))
.expect("code line rendered");
let tail = lines
.iter()
.position(|line| line.contains(WRAP_TAIL))
.expect("code tail rendered");
assert!(tail > start, "the code line must have wrapped: {lines:?}");
let indent = lines[start].len() - lines[start].trim_start().len();
assert!(indent > 0, "code blocks are indented: {lines:?}");
for line in &lines[start + 1..=tail] {
assert!(
line.starts_with(&" ".repeat(indent)),
"code continuation lost its column: {line:?} in {lines:?}"
);
}
}
#[test]
fn render_cache_is_dropped_when_the_body_width_changes() {
let (_tx, model) = new_model();
let mut sim = ProgramSimulator::new(model);
sim.init();
for i in 0..4 {
finish_turn(&mut sim, &format!("message **{i}** body"));
}
let _ = sim.model().conversation_text(80);
assert_eq!(sim.model().render_stats.get(), (4, 0), "cold frame");
let _ = sim.model().conversation_text(80);
assert_eq!(sim.model().render_stats.get(), (0, 4), "warm frame reuses");
let _ = sim.model().conversation_text(120);
assert_eq!(
sim.model().render_stats.get(),
(4, 0),
"a width change must invalidate wrapped blocks"
);
let _ = sim.model().conversation_text(120);
assert_eq!(sim.model().render_stats.get(), (0, 4), "then stay warm");
}
#[test]
fn deep_transcript_still_renders_its_tail() {
let (_tx, mut model) = new_model();
for i in 0..2000 {
model.push_entry(EntryRole::System, format!("line-{i}"));
}
let mut sim = ProgramSimulator::new(model);
sim.init();
let (width, height) = (40_u16, 12_u16);
let rendered = buffer_text(sim.capture_frame(width, height), width, height);
assert!(
rendered.contains("line-1999"),
"tail-follow lost the newest line:\n{rendered}"
);
assert!(
!rendered.contains("line-0 "),
"the top of the transcript must be scrolled off:\n{rendered}"
);
}
#[test]
fn scroll_total_counts_wrapped_rows() {
let (_tx, mut model) = new_model();
model.push_entry(EntryRole::Assistant, wrap_probe_paragraph());
let mut sim = ProgramSimulator::new(model);
sim.init();
let narrow = sim.model().conversation_text(40).lines().len();
let wide = sim.model().conversation_text(240).lines().len();
assert!(
narrow > wide,
"a narrower body must produce more rows: narrow={narrow}, wide={wide}"
);
let _ = sim.capture_frame(40, 20);
assert_eq!(
sim.model().rendered_total_lines.get(),
narrow,
"the frame must record the wrapped total"
);
}
}
#[cfg(test)]
mod loop_watchdog_tests {
use super::{LOOP_STALL_BUDGET, LoopPhase, LoopWatchdog};
use std::time::{Duration, Instant};
fn started_ago(over: Duration) -> Option<Instant> {
Instant::now().checked_sub(over)
}
#[test]
fn disabled_watchdog_reads_no_clock_and_records_nothing() {
let wd = LoopWatchdog::with_enabled(false);
assert!(wd.start().is_none());
wd.finish(LoopPhase::Render, None);
let snap = wd.snapshot();
assert_eq!(snap["verdict"], "disabled");
assert_eq!(snap["enabled"], false);
assert_eq!(snap["totals"]["stalls"], 0);
assert_eq!(snap["phases"]["render"]["samples"], 0);
}
#[test]
fn enabled_watchdog_attributes_samples_per_phase() {
let wd = LoopWatchdog::with_enabled(true);
assert!(wd.start().is_some(), "enabled watchdog reads the clock");
for phase in [LoopPhase::Render, LoopPhase::Input, LoopPhase::AgentEvent] {
wd.finish(phase, wd.start());
}
wd.finish(LoopPhase::Render, wd.start());
let snap = wd.snapshot();
assert_eq!(snap["schema"], "pi.tui.loop_watchdog.v1");
assert_eq!(snap["surface"], "ftui");
assert_eq!(snap["phases"]["render"]["samples"], 2);
assert_eq!(snap["phases"]["input"]["samples"], 1);
assert_eq!(snap["phases"]["agent_event"]["samples"], 1);
assert_eq!(snap["totals"]["stalls"], 0);
assert_eq!(snap["verdict"], "pass");
}
#[test]
fn over_budget_phase_is_counted_and_attributed() {
let wd = LoopWatchdog::with_enabled(true);
let Some(started) = started_ago(LOOP_STALL_BUDGET + Duration::from_millis(50)) else {
return; };
wd.finish(LoopPhase::AgentEvent, Some(started));
let snap = wd.snapshot();
assert_eq!(snap["totals"]["stalls"], 1);
assert_eq!(snap["phases"]["agent_event"]["stalls"], 1);
assert_eq!(snap["phases"]["render"]["stalls"], 0);
assert_eq!(snap["phases"]["input"]["stalls"], 0);
assert_eq!(snap["verdict"], "warn");
let worst = snap["phases"]["agent_event"]["worst_us"]
.as_u64()
.expect("worst_us is a number");
assert!(
worst >= u64::try_from(LOOP_STALL_BUDGET.as_micros()).unwrap_or(u64::MAX),
"worst_us {worst} should be at least the budget"
);
}
#[test]
fn sustained_stall_latches_until_a_phase_comes_back_in_budget() {
let wd = LoopWatchdog::with_enabled(true);
let over = LOOP_STALL_BUDGET + Duration::from_millis(10);
let Some(first) = started_ago(over) else {
return;
};
wd.finish(LoopPhase::Render, Some(first));
assert!(wd.stalled.get(), "first over-budget call arms the latch");
for _ in 0..2 {
let Some(again) = started_ago(over) else {
return;
};
wd.finish(LoopPhase::Render, Some(again));
assert!(wd.stalled.get(), "latch stays armed through the stall");
}
assert_eq!(wd.snapshot()["totals"]["stalls"], 3);
wd.finish(LoopPhase::Render, wd.start());
assert!(
!wd.stalled.get(),
"an in-budget phase clears the latch for the next stall"
);
}
}