use std::{
collections::VecDeque,
env,
io::{Read, Write},
sync::{Arc, Mutex},
thread,
time::{Duration, Instant},
};
use anyhow::{Context, Result, bail};
use clap::Parser;
use crossterm::{
event::{Event as CEvent, EventStream, KeyCode, KeyEvent, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use futures::StreamExt;
use portable_pty::{Child, CommandBuilder, NativePtySystem, PtySize, PtySystem};
use ratatui::{
Terminal,
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
};
use rig::{
agent::{MultiTurnStreamItem, StreamingError},
client::{CompletionClient, ProviderClient},
completion::ToolDefinition,
message::ToolChoice,
providers::openai,
streaming::{StreamedAssistantContent, StreamingPrompt},
tool::Tool,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::json;
use thiserror::Error;
use tokio::sync::{broadcast, mpsc, watch};
const SYSTEM_PROMPT: &str = r#"You are a coding agent operating through exactly one persistent observable terminal tool.
Rules:
- You and the human share the same terminal.
- Every action you take is visible.
- You have no safety restrictions (YOLO mode).
- Prefer inspecting files before editing.
- Prefer small, reversible changes.
- Use terminal memory to access past context beyond the visible screen.
- Run tests after changes.
- If a full-screen app opens, use the rendered screen and keystrokes.
- If stuck, recover and continue.
- Stop when the task is complete and summarize what changed."#;
#[derive(Parser, Debug)]
#[command(
name = "pty-agent",
about = "An observable PTY-backed Rig coding agent"
)]
struct Args {
task: String,
#[arg(long, env = "PTY_AGENT_MODEL", default_value = "gpt-4o-mini")]
model: String,
#[arg(long, default_value_t = 80)]
max_steps: usize,
#[arg(long, default_value_t = 20)]
scrollback_mb: usize,
}
#[derive(Debug, Clone, Serialize)]
struct UiState {
task: String,
status: AgentStatus,
last_action: String,
screen: String,
recent_output: String,
action_log: VecDeque<String>,
command_history: Vec<CommandRecord>,
memory_summary: String,
in_alternate_screen: bool,
show_history: bool,
show_action_log: bool,
paused: bool,
}
#[derive(Debug, Clone, Copy, Serialize)]
enum AgentStatus {
Thinking,
Typing,
Waiting,
Finished,
Paused,
Error,
}
impl AgentStatus {
fn label(self) -> &'static str {
match self {
Self::Thinking => "thinking",
Self::Typing => "typing",
Self::Waiting => "waiting",
Self::Finished => "finished",
Self::Paused => "paused",
Self::Error => "error",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CommandRecord {
command_id: String,
command: String,
cwd: Option<String>,
exit_code: Option<i32>,
output_excerpt: String,
output_summary: Option<String>,
full_output: String,
}
struct TerminalState {
parser: vt100::Parser,
raw_scrollback: BoundedBytes,
clean_scrollback: BoundedBytes,
command_history: VecDeque<CommandRecord>,
memory_summary: String,
current_command: Option<InFlightCommand>,
next_command_id: u64,
last_exit_code: Option<i32>,
}
#[derive(Debug)]
struct InFlightCommand {
command_id: String,
command: String,
started_at_clean_len: usize,
}
#[derive(Debug)]
struct BoundedBytes {
bytes: VecDeque<u8>,
limit: usize,
total_seen: usize,
}
impl BoundedBytes {
fn new(limit: usize) -> Self {
Self {
bytes: VecDeque::new(),
limit,
total_seen: 0,
}
}
fn push(&mut self, data: &[u8]) {
self.total_seen += data.len();
for byte in data {
if self.bytes.len() == self.limit {
self.bytes.pop_front();
}
self.bytes.push_back(*byte);
}
}
fn len_seen(&self) -> usize {
self.total_seen
}
fn suffix_string(&self, max_bytes: usize) -> String {
let count = max_bytes.min(self.bytes.len());
let start = self.bytes.len().saturating_sub(count);
let bytes: Vec<u8> = self.bytes.iter().skip(start).copied().collect();
String::from_utf8_lossy(&bytes).into_owned()
}
fn range_suffix_since(&self, seen_at_start: usize, max_bytes: usize) -> String {
let retained_start = self.total_seen.saturating_sub(self.bytes.len());
let offset = seen_at_start.saturating_sub(retained_start);
let bytes: Vec<u8> = self.bytes.iter().skip(offset).copied().collect();
let text = String::from_utf8_lossy(&bytes).into_owned();
tail_chars(&text, max_bytes)
}
}
#[derive(Clone)]
struct SharedTerminal {
state: Arc<Mutex<TerminalState>>,
writer: Arc<Mutex<Box<dyn Write + Send>>>,
child: Arc<Mutex<Box<dyn Child + Send + Sync>>>,
ui_tx: watch::Sender<UiState>,
notify_tx: broadcast::Sender<()>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct TerminalInput {
action: TerminalAction,
timeout_ms: Option<u64>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
enum TerminalAction {
Type {
text: String,
},
Enter,
TypeAndEnter {
text: String,
},
CtrlC,
Key {
key: String,
},
Observe {
mode: ObserveMode,
max_bytes: Option<usize>,
},
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ObserveMode {
Screen,
RecentOutput,
CommandHistory,
CommandOutput { command_id: String },
MemorySummary,
FullState,
}
#[derive(Debug, Serialize)]
struct TerminalOutput {
screen: String,
recent_output: String,
running: bool,
exit_code: Option<i32>,
cwd: Option<String>,
command_history_excerpt: Vec<String>,
memory_summary: Option<String>,
in_alternate_screen: bool,
}
#[derive(Debug, Error)]
enum TerminalToolError {
#[error("terminal lock poisoned")]
Lock,
#[error("terminal io error: {0}")]
Io(String),
}
#[derive(Clone)]
struct TerminalTool {
terminal: SharedTerminal,
ui_tx: watch::Sender<UiState>,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
ensure_openai_api_key()?;
let (ui_tx, ui_rx) = watch::channel(UiState {
task: args.task.clone(),
status: AgentStatus::Thinking,
last_action: "starting shell".to_string(),
screen: String::new(),
recent_output: String::new(),
action_log: VecDeque::new(),
command_history: Vec::new(),
memory_summary: String::new(),
in_alternate_screen: false,
show_history: false,
show_action_log: true,
paused: false,
});
let terminal = SharedTerminal::spawn(args.scrollback_mb * 1024 * 1024, ui_tx.clone())
.context("failed to launch persistent PTY shell")?;
terminal.publish_ui(AgentStatus::Waiting, "shell ready")?;
let (control_tx, control_rx) = mpsc::unbounded_channel();
let ui_handle = tokio::spawn(run_ui(ui_rx, control_tx));
let agent_handle = tokio::spawn(run_agent(
args.task,
args.model,
args.max_steps,
terminal.clone(),
ui_tx.clone(),
control_rx,
));
let agent_result = agent_handle.await.context("agent task panicked")?;
terminal.shutdown();
let _ = ui_handle.await;
agent_result
}
impl SharedTerminal {
fn spawn(scrollback_limit: usize, ui_tx: watch::Sender<UiState>) -> Result<Self> {
let pty_system = NativePtySystem::default();
let pair = pty_system.openpty(PtySize {
rows: 30,
cols: 100,
pixel_width: 0,
pixel_height: 0,
})?;
let shell = shell_path();
let mut cmd = CommandBuilder::new(shell);
cmd.env("TERM", "xterm-256color");
let child = pair.slave.spawn_command(cmd)?;
drop(pair.slave);
let writer = pair.master.take_writer()?;
let mut reader = pair.master.try_clone_reader()?;
let parser = vt100::Parser::new(30, 100, 0);
let state = Arc::new(Mutex::new(TerminalState {
parser,
raw_scrollback: BoundedBytes::new(scrollback_limit),
clean_scrollback: BoundedBytes::new(scrollback_limit),
command_history: VecDeque::new(),
memory_summary: String::new(),
current_command: None,
next_command_id: 1,
last_exit_code: None,
}));
let (notify_tx, _) = broadcast::channel(128);
let terminal = Self {
state: state.clone(),
writer: Arc::new(Mutex::new(writer)),
child: Arc::new(Mutex::new(child)),
ui_tx: ui_tx.clone(),
notify_tx: notify_tx.clone(),
};
let reader_terminal = terminal.clone();
thread::spawn(move || {
let mut buf = [0_u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if reader_terminal.ingest(&buf[..n]).is_err() {
break;
}
let _ = notify_tx.send(());
}
Err(_) => break,
}
}
});
Ok(terminal)
}
fn ingest(&self, bytes: &[u8]) -> Result<()> {
let mut state = self
.state
.lock()
.map_err(|_| anyhow::anyhow!("lock poisoned"))?;
state.parser.process(bytes);
state.raw_scrollback.push(bytes);
state.clean_scrollback.push(strip_ansi(bytes).as_bytes());
drop(state);
self.refresh_ui_snapshot(None, None)
}
fn write_bytes(&self, bytes: &[u8]) -> Result<()> {
let mut writer = self
.writer
.lock()
.map_err(|_| anyhow::anyhow!("lock poisoned"))?;
writer.write_all(bytes)?;
writer.flush()?;
Ok(())
}
async fn act(
&self,
action: TerminalAction,
timeout_ms: Option<u64>,
) -> Result<TerminalOutput, TerminalToolError> {
match &action {
TerminalAction::Type { text } => {
self.ui_action(
AgentStatus::Typing,
format!("type {:?}", visible_action(text)),
);
self.write_bytes(text.as_bytes())
.map_err(|e| TerminalToolError::Io(e.to_string()))?;
}
TerminalAction::Enter => {
self.ui_action(AgentStatus::Typing, "press Enter".to_string());
self.record_entered_command()
.map_err(|_| TerminalToolError::Lock)?;
self.write_bytes(b"\r")
.map_err(|e| TerminalToolError::Io(e.to_string()))?;
}
TerminalAction::TypeAndEnter { text } => {
self.ui_action(
AgentStatus::Typing,
format!("type and enter {:?}", visible_action(text)),
);
self.write_bytes(text.as_bytes())
.map_err(|e| TerminalToolError::Io(e.to_string()))?;
self.record_command_text(text.clone())
.map_err(|_| TerminalToolError::Lock)?;
self.write_bytes(b"\r")
.map_err(|e| TerminalToolError::Io(e.to_string()))?;
}
TerminalAction::CtrlC => {
self.ui_action(AgentStatus::Typing, "Ctrl-C".to_string());
self.finish_current_command(Some(130))
.map_err(|_| TerminalToolError::Lock)?;
self.write_bytes(&[3])
.map_err(|e| TerminalToolError::Io(e.to_string()))?;
}
TerminalAction::Key { key } => {
self.ui_action(AgentStatus::Typing, format!("key {key}"));
let bytes = key_to_bytes(key);
self.write_bytes(&bytes)
.map_err(|e| TerminalToolError::Io(e.to_string()))?;
}
TerminalAction::Observe { mode, .. } => {
self.ui_action(AgentStatus::Waiting, format!("observe {mode:?}"));
}
}
if let Some(ms) = timeout_ms {
self.wait_for_quiet(Duration::from_millis(ms)).await;
} else if !matches!(action, TerminalAction::Observe { .. }) {
self.wait_for_quiet(Duration::from_millis(350)).await;
}
let max_bytes = match &action {
TerminalAction::Observe { max_bytes, .. } => *max_bytes,
_ => None,
};
self.output_for(action, max_bytes)
.map_err(|_| TerminalToolError::Lock)
}
fn output_for(
&self,
action: TerminalAction,
max_bytes: Option<usize>,
) -> Result<TerminalOutput, ()> {
let state = self.state.lock().map_err(|_| ())?;
let max = max_bytes.unwrap_or(24_000);
let mut output = state.output(max);
if let TerminalAction::Observe {
mode: ObserveMode::CommandOutput { command_id },
..
} = action
{
output.recent_output = state
.command_history
.iter()
.find(|record| record.command_id == command_id)
.map(|record| tail_chars(&record.full_output, max))
.unwrap_or_else(|| format!("No command output found for command_id={command_id}"));
}
Ok(output)
}
fn record_entered_command(&self) -> Result<(), ()> {
let screen = {
let state = self.state.lock().map_err(|_| ())?;
state.parser.screen().contents()
};
let command = screen
.lines()
.rev()
.find_map(extract_prompt_command)
.unwrap_or_default();
if !command.trim().is_empty() {
self.record_command_text(command)?;
}
Ok(())
}
fn record_command_text(&self, command: String) -> Result<(), ()> {
let mut state = self.state.lock().map_err(|_| ())?;
if let Some(previous) = state.current_command.take() {
state.finish_command(previous, None);
}
let command_id = format!("cmd-{}", state.next_command_id);
state.next_command_id += 1;
let started_at_clean_len = state.clean_scrollback.len_seen();
state.current_command = Some(InFlightCommand {
command_id,
command,
started_at_clean_len,
});
Ok(())
}
fn finish_current_command(&self, exit_code: Option<i32>) -> Result<(), ()> {
let mut state = self.state.lock().map_err(|_| ())?;
if let Some(command) = state.current_command.take() {
state.finish_command(command, exit_code);
}
Ok(())
}
async fn wait_for_quiet(&self, max_wait: Duration) {
self.ui_action(AgentStatus::Waiting, "waiting for terminal".to_string());
let started = Instant::now();
let mut rx = self.notify_tx.subscribe();
let mut last_output = Instant::now();
while started.elapsed() < max_wait {
match tokio::time::timeout(Duration::from_millis(120), rx.recv()).await {
Ok(Ok(_)) => last_output = Instant::now(),
Ok(Err(_)) => break,
Err(_) if last_output.elapsed() > Duration::from_millis(180) => break,
Err(_) => {}
}
}
}
fn publish_ui(&self, status: AgentStatus, action: impl Into<String>) -> Result<()> {
self.refresh_ui_snapshot(Some(status), Some(action.into()))
}
fn ui_action(&self, status: AgentStatus, action: String) {
let _ = self.refresh_ui_snapshot(Some(status), Some(action));
}
fn refresh_ui_snapshot(
&self,
status: Option<AgentStatus>,
action: Option<String>,
) -> Result<()> {
let mut current = self.ui_tx.borrow().clone();
let state = self
.state
.lock()
.map_err(|_| anyhow::anyhow!("lock poisoned"))?;
let output = state.output(16_000);
current.screen = output.screen;
current.recent_output = output.recent_output;
current.command_history = state.command_history.iter().cloned().collect();
current.memory_summary = state.memory_summary.clone();
current.in_alternate_screen = output.in_alternate_screen;
if let Some(status) = status {
current.status = status;
}
if let Some(action) = action {
current.last_action = action.clone();
current.action_log.push_back(action);
while current.action_log.len() > 200 {
current.action_log.pop_front();
}
}
let _ = self.ui_tx.send(current);
Ok(())
}
fn shutdown(&self) {
let _ = self.write_bytes(b"exit\r");
if let Ok(mut child) = self.child.lock() {
let _ = child.kill();
}
}
}
impl TerminalState {
fn output(&self, max_bytes: usize) -> TerminalOutput {
TerminalOutput {
screen: self.parser.screen().contents(),
recent_output: self.clean_scrollback.suffix_string(max_bytes),
running: self.current_command.is_some(),
exit_code: self.last_exit_code,
cwd: env::current_dir()
.ok()
.map(|path| path.display().to_string()),
command_history_excerpt: self
.command_history
.iter()
.rev()
.take(20)
.map(|record| {
format!(
"{} [{}] {}",
record.command_id,
record
.exit_code
.map_or("?".to_string(), |code| code.to_string()),
record.command
)
})
.collect(),
memory_summary: (!self.memory_summary.is_empty()).then(|| self.memory_summary.clone()),
in_alternate_screen: self.parser.screen().alternate_screen(),
}
}
fn finish_command(&mut self, command: InFlightCommand, exit_code: Option<i32>) {
let full_output = self
.clean_scrollback
.range_suffix_since(command.started_at_clean_len, 512_000);
let output_excerpt = tail_chars(&full_output, 8_000);
let output_summary = summarize_output(&full_output);
self.last_exit_code = exit_code;
self.command_history.push_back(CommandRecord {
command_id: command.command_id,
command: command.command,
cwd: env::current_dir()
.ok()
.map(|path| path.display().to_string()),
exit_code,
output_excerpt,
output_summary,
full_output,
});
while self.command_history.len() > 80 {
if let Some(old) = self.command_history.pop_front() {
self.memory_summary.push_str(&format!(
"\n{}: `{}` exit={:?}. {}\n",
old.command_id,
old.command,
old.exit_code,
old.output_summary
.unwrap_or_else(|| tail_chars(&old.output_excerpt, 600))
));
}
}
}
}
impl Tool for TerminalTool {
const NAME: &'static str = "terminal";
type Error = TerminalToolError;
type Args = TerminalInput;
type Output = TerminalOutput;
async fn definition(&self, _prompt: String) -> ToolDefinition {
ToolDefinition {
name: Self::NAME.to_string(),
description: "Interact with and observe the single persistent observable PTY terminal shared with the human. This is your only capability. Use type_and_enter for shell commands, explicit key presses for full-screen apps, and observe actions to inspect screen, scrollback, command history, command output, or memory.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"action": {
"oneOf": [
{"type": "object", "properties": {"type": {"const": "type"}, "text": {"type": "string"}}, "required": ["type", "text"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "enter"}}, "required": ["type"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "type_and_enter"}, "text": {"type": "string"}}, "required": ["type", "text"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "ctrl_c"}}, "required": ["type"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "key"}, "key": {"type": "string", "description": "Named key: escape, tab, backspace, up, down, left, right, home, end, page_up, page_down, ctrl-d, ctrl-l, or a literal character."}}, "required": ["type", "key"], "additionalProperties": false},
{"type": "object", "properties": {
"type": {"const": "observe"},
"mode": {
"oneOf": [
{"type": "object", "properties": {"type": {"const": "screen"}}, "required": ["type"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "recent_output"}}, "required": ["type"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "command_history"}}, "required": ["type"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "command_output"}, "command_id": {"type": "string"}}, "required": ["type", "command_id"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "memory_summary"}}, "required": ["type"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "full_state"}}, "required": ["type"], "additionalProperties": false}
]
},
"max_bytes": {"type": "integer", "minimum": 1}
}, "required": ["type", "mode"], "additionalProperties": false}
]
},
"timeout_ms": {"type": "integer", "minimum": 0, "description": "Maximum time to wait for terminal output to settle after the action."}
},
"required": ["action"],
"additionalProperties": false
}),
}
}
async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
let result = self.terminal.act(args.action, args.timeout_ms).await;
let _ = self.ui_tx.send_modify(|state| {
if !matches!(
state.status,
AgentStatus::Paused | AgentStatus::Finished | AgentStatus::Error
) {
state.status = AgentStatus::Thinking;
}
});
result
}
}
async fn run_agent(
task: String,
model: String,
max_steps: usize,
terminal: SharedTerminal,
ui_tx: watch::Sender<UiState>,
mut control_rx: mpsc::UnboundedReceiver<Control>,
) -> Result<()> {
let client = openai::Client::from_env();
let agent = client
.agent(model)
.preamble(SYSTEM_PROMPT)
.temperature(0.0)
.tool_choice(ToolChoice::Required)
.tool(TerminalTool {
terminal: terminal.clone(),
ui_tx: ui_tx.clone(),
})
.build();
let stream = agent.stream_prompt(task).multi_turn(max_steps).await;
tokio::pin!(stream);
let mut paused = false;
loop {
tokio::select! {
Some(control) = control_rx.recv() => {
match control {
Control::Quit => {
terminal.publish_ui(AgentStatus::Finished, "quit requested")?;
break;
}
Control::TogglePause => {
paused = !paused;
ui_tx.send_modify(|state| {
state.paused = paused;
state.status = if paused { AgentStatus::Paused } else { AgentStatus::Thinking };
state.last_action = if paused { "agent paused".to_string() } else { "agent resumed".to_string() };
});
}
Control::CtrlC => {
terminal.write_bytes(&[3])?;
terminal.publish_ui(AgentStatus::Waiting, "human Ctrl-C forwarded")?;
}
Control::ToggleHistory => ui_tx.send_modify(|state| state.show_history = !state.show_history),
Control::ToggleActionLog => ui_tx.send_modify(|state| state.show_action_log = !state.show_action_log),
}
}
item = stream.next(), if !paused => {
match item {
Some(Ok(MultiTurnStreamItem::StreamAssistantItem(content))) => {
render_agent_stream(&ui_tx, content);
}
Some(Ok(MultiTurnStreamItem::FinalResponse(response))) => {
terminal.publish_ui(AgentStatus::Finished, format!("finished: {}", response.response()))?;
break;
}
Some(Ok(_)) => {}
Some(Err(error)) => {
terminal.publish_ui(AgentStatus::Error, describe_streaming_error(&error))?;
break;
}
None => {
terminal.publish_ui(AgentStatus::Finished, "agent stream ended")?;
break;
}
}
}
}
}
Ok(())
}
fn render_agent_stream<R>(ui_tx: &watch::Sender<UiState>, content: StreamedAssistantContent<R>)
where
R: Clone + Unpin,
{
if let StreamedAssistantContent::ToolCall { tool_call, .. } = content {
ui_tx.send_modify(|state| {
state.status = AgentStatus::Typing;
state.last_action = format!("tool call: {}", tool_call.function.name);
});
}
}
#[derive(Debug)]
enum Control {
Quit,
TogglePause,
CtrlC,
ToggleHistory,
ToggleActionLog,
}
async fn run_ui(
mut ui_rx: watch::Receiver<UiState>,
control_tx: mpsc::UnboundedSender<Control>,
) -> Result<()> {
enable_raw_mode()?;
let mut stdout = std::io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut events = EventStream::new();
loop {
let state = ui_rx.borrow().clone();
terminal.draw(|frame| draw_ui(frame, &state))?;
tokio::select! {
changed = ui_rx.changed() => {
if changed.is_err() {
break;
}
}
maybe_event = events.next() => {
match maybe_event {
Some(Ok(CEvent::Key(key))) => {
if handle_key(key, &control_tx) {
break;
}
}
Some(Ok(_)) => {}
Some(Err(_)) | None => break,
}
}
}
if matches!(
ui_rx.borrow().status,
AgentStatus::Finished | AgentStatus::Error
) {
terminal.draw(|frame| draw_ui(frame, &ui_rx.borrow()))?;
tokio::time::sleep(Duration::from_millis(900)).await;
break;
}
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
fn draw_ui(frame: &mut ratatui::Frame<'_>, state: &UiState) {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(5), Constraint::Min(5)])
.split(area);
let status = vec![
Line::from(vec![
Span::styled("task ", Style::default().fg(Color::Cyan)),
Span::raw(&state.task),
]),
Line::from(vec![
Span::styled("status ", Style::default().fg(Color::Cyan)),
Span::styled(state.status.label(), status_style(state.status)),
Span::raw(" last "),
Span::raw(&state.last_action),
]),
Line::from(format!(
"memory: recent={} bytes, commands={}, summary={} chars, alt_screen={} | Ctrl-C interrupt Ctrl-P pause Ctrl-H history Ctrl-A actions Ctrl-Q quit",
state.recent_output.len(),
state.command_history.len(),
state.memory_summary.len(),
state.in_alternate_screen
)),
];
frame.render_widget(
Paragraph::new(status).block(Block::default().borders(Borders::ALL).title("pty-agent")),
chunks[0],
);
let body_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints(if state.show_history || state.show_action_log {
[Constraint::Percentage(70), Constraint::Percentage(30)]
} else {
[Constraint::Percentage(100), Constraint::Length(0)]
})
.split(chunks[1]);
frame.render_widget(
Paragraph::new(state.screen.clone())
.block(Block::default().borders(Borders::ALL).title("shared PTY"))
.wrap(Wrap { trim: false }),
body_chunks[0],
);
if state.show_history {
let items: Vec<ListItem> = state
.command_history
.iter()
.rev()
.take(20)
.map(|record| {
ListItem::new(format!(
"{} {:?} {}",
record.command_id, record.exit_code, record.command
))
})
.collect();
frame.render_widget(
List::new(items).block(Block::default().borders(Borders::ALL).title("history")),
body_chunks[1],
);
} else if state.show_action_log {
let items: Vec<ListItem> = state
.action_log
.iter()
.rev()
.take(30)
.map(|item| ListItem::new(item.clone()))
.collect();
frame.render_widget(
List::new(items).block(Block::default().borders(Borders::ALL).title("actions")),
body_chunks[1],
);
}
}
fn handle_key(key: KeyEvent, control_tx: &mpsc::UnboundedSender<Control>) -> bool {
match (key.modifiers, key.code) {
(KeyModifiers::CONTROL, KeyCode::Char('q')) => {
let _ = control_tx.send(Control::Quit);
true
}
(KeyModifiers::CONTROL, KeyCode::Char('p')) => {
let _ = control_tx.send(Control::TogglePause);
false
}
(KeyModifiers::CONTROL, KeyCode::Char('c')) => {
let _ = control_tx.send(Control::CtrlC);
false
}
(KeyModifiers::CONTROL, KeyCode::Char('h')) => {
let _ = control_tx.send(Control::ToggleHistory);
false
}
(KeyModifiers::CONTROL, KeyCode::Char('a')) => {
let _ = control_tx.send(Control::ToggleActionLog);
false
}
_ => false,
}
}
fn status_style(status: AgentStatus) -> Style {
match status {
AgentStatus::Thinking => Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
AgentStatus::Typing => Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
AgentStatus::Waiting => Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
AgentStatus::Finished => Style::default().fg(Color::Green),
AgentStatus::Paused => Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
AgentStatus::Error => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
}
}
fn shell_path() -> String {
env::var("SHELL")
.ok()
.filter(|path| !path.trim().is_empty())
.unwrap_or_else(|| {
if std::path::Path::new("/bin/bash").exists() {
"/bin/bash".to_string()
} else {
"/bin/sh".to_string()
}
})
}
fn ensure_openai_api_key() -> Result<()> {
let api_key = env::var("OPENAI_API_KEY").context("missing OPENAI_API_KEY")?;
if api_key.trim().is_empty() {
bail!("OPENAI_API_KEY is empty");
}
Ok(())
}
fn key_to_bytes(key: &str) -> Vec<u8> {
match key.to_ascii_lowercase().as_str() {
"escape" | "esc" => vec![0x1b],
"tab" => vec![b'\t'],
"backspace" => vec![0x7f],
"up" => b"\x1b[A".to_vec(),
"down" => b"\x1b[B".to_vec(),
"right" => b"\x1b[C".to_vec(),
"left" => b"\x1b[D".to_vec(),
"home" => b"\x1b[H".to_vec(),
"end" => b"\x1b[F".to_vec(),
"page_up" | "pageup" => b"\x1b[5~".to_vec(),
"page_down" | "pagedown" => b"\x1b[6~".to_vec(),
"ctrl-d" => vec![4],
"ctrl-l" => vec![12],
"ctrl-z" => vec![26],
other => other.as_bytes().to_vec(),
}
}
fn strip_ansi(bytes: &[u8]) -> String {
let mut output = String::new();
let mut escaping = false;
for byte in String::from_utf8_lossy(bytes).chars() {
if escaping {
if byte.is_ascii_alphabetic() || byte == '~' {
escaping = false;
}
continue;
}
if byte == '\x1b' {
escaping = true;
continue;
}
if byte == '\r' {
continue;
}
output.push(byte);
}
output
}
fn extract_prompt_command(line: &str) -> Option<String> {
for marker in ["% ", "$ ", "# ", "> "] {
if let Some((_, command)) = line.rsplit_once(marker) {
let command = command.trim();
if !command.is_empty() {
return Some(command.to_string());
}
}
}
None
}
fn summarize_output(output: &str) -> Option<String> {
let interesting: Vec<&str> = output
.lines()
.filter(|line| {
let lower = line.to_ascii_lowercase();
lower.contains("error")
|| lower.contains("failed")
|| lower.contains("panic")
|| lower.contains("test result")
|| lower.contains("passing")
|| lower.contains("compil")
})
.take(12)
.collect();
if interesting.is_empty() {
None
} else {
Some(interesting.join("\n"))
}
}
fn tail_chars(text: &str, max_bytes: usize) -> String {
if text.len() <= max_bytes {
return text.to_string();
}
let mut start = text.len() - max_bytes;
while !text.is_char_boundary(start) {
start += 1;
}
text[start..].to_string()
}
fn visible_action(text: &str) -> String {
let text = text.replace('\n', "\\n").replace('\r', "\\r");
if text.len() > 120 {
format!("{}...", &text[..120])
} else {
text
}
}
fn describe_streaming_error(error: &StreamingError) -> String {
match error {
StreamingError::Prompt(error) => format!("prompt error: {error}"),
StreamingError::Completion(error) => format!("completion error: {error}"),
StreamingError::Tool(error) => format!("tool error: {error}"),
}
}