use crate::types::{
CliType, ClaudeStatus, PermissionMode, SessionState, SessionStatus, IDLE_THRESHOLD,
};
use portable_pty::{Child, MasterPty, PtySize};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Instant, SystemTime};
pub(crate) struct Session {
pub(crate) id: usize,
pub(crate) name: String,
pub(crate) cli_type: CliType,
pub(crate) status: SessionStatus,
#[allow(dead_code)]
pub(crate) created_at: Instant,
parser: vt100::Parser,
pty_master: Option<Box<dyn MasterPty + Send>>,
pty_writer: Option<Box<dyn Write + Send>>,
#[allow(dead_code)]
child: Option<Box<dyn Child + Send>>,
last_size: (u16, u16),
status_file: PathBuf,
pub(crate) claude_status: Option<ClaudeStatus>,
status_file_mtime: Option<SystemTime>,
last_pty_output: Option<Instant>,
pub(crate) scroll_offset: usize,
pub(crate) turn_count: u32,
pub(crate) permission_mode: PermissionMode,
transcript_mtime: Option<SystemTime>,
}
impl Session {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
id: usize,
name: String,
cli_type: CliType,
status: SessionStatus,
parser: vt100::Parser,
pty_master: Option<Box<dyn MasterPty + Send>>,
pty_writer: Option<Box<dyn Write + Send>>,
child: Option<Box<dyn Child + Send>>,
last_size: (u16, u16),
status_file: PathBuf,
) -> Self {
let has_pty = pty_writer.is_some();
Self {
id,
name,
cli_type,
status,
created_at: Instant::now(),
parser,
pty_master,
pty_writer,
child,
last_size,
status_file,
claude_status: None,
status_file_mtime: None,
last_pty_output: if has_pty { Some(Instant::now()) } else { None },
scroll_offset: 0,
turn_count: 0,
permission_mode: PermissionMode::Unknown,
transcript_mtime: None,
}
}
pub(crate) fn new_failed(
id: usize,
name: String,
cli_type: CliType,
parser: vt100::Parser,
last_size: (u16, u16),
status_file: PathBuf,
) -> Self {
Self::new(
id,
name,
cli_type,
SessionStatus::Failed,
parser,
None,
None,
None,
last_size,
status_file,
)
}
pub(crate) fn process_pty_output(&mut self, bytes: &[u8]) {
self.parser.process(bytes);
self.last_pty_output = Some(Instant::now());
}
pub(crate) fn mark_exited(&mut self) {
self.status = SessionStatus::Completed;
self.pty_writer = None;
}
pub(crate) fn write_to_pty(&mut self, bytes: &[u8]) {
if let Some(writer) = &mut self.pty_writer {
let _ = writer.write_all(bytes);
let _ = writer.flush();
}
}
pub(crate) fn send_shutdown_signals(&mut self) {
if self.status == SessionStatus::Running {
self.write_to_pty(b"\x03"); self.write_to_pty(b"\x04"); }
}
pub(crate) fn drop_pty(&mut self) {
self.pty_writer = None;
self.pty_master = None;
}
pub(crate) fn screen(&self) -> &vt100::Screen {
self.parser.screen()
}
pub(crate) fn set_scrollback(&mut self, offset: usize) {
self.parser.screen_mut().set_scrollback(offset);
}
pub(crate) fn resize(&mut self, rows: u16, cols: u16) {
if (rows, cols) != self.last_size {
self.parser.screen_mut().set_size(rows, cols);
if let Some(master) = &self.pty_master {
let _ = master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
}
self.last_size = (rows, cols);
}
}
pub(crate) fn max_scrollback(&mut self) -> usize {
self.parser.screen_mut().set_scrollback(usize::MAX);
let max = self.parser.screen().scrollback();
self.parser.screen_mut().set_scrollback(0);
max
}
pub(crate) fn clamp_scroll(&mut self) {
let max = self.max_scrollback();
self.scroll_offset = self.scroll_offset.min(max);
}
pub(crate) fn is_actively_working(&self) -> bool {
match self.last_pty_output {
Some(t) => t.elapsed() < IDLE_THRESHOLD,
None => false,
}
}
pub(crate) fn is_showing_plan_prompt(&self) -> bool {
let screen = self.parser.screen();
let contents = screen.contents();
contents.contains("Claude has written up a plan and is ready to execute. Would you like to proceed?")
}
pub(crate) fn is_waiting_for_input(&self) -> bool {
let screen = self.parser.screen();
let contents = screen.contents();
if contents.contains("Enter to select") && contents.contains("to navigate") {
return true;
}
if contents.contains("Allow Claude") || contents.contains("Allow Amp") {
return true;
}
contents
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.is_some_and(|line| line.trim_end().ends_with('>'))
}
pub(crate) fn inferred_state(&self) -> SessionState {
match self.status {
SessionStatus::Completed => SessionState::Closed,
SessionStatus::Failed => SessionState::Failed,
SessionStatus::Running => {
if self.is_actively_working() {
SessionState::Working
} else if self.cli_type != CliType::Console && self.is_showing_plan_prompt() {
SessionState::Planned
} else if self.cli_type != CliType::Console && self.is_waiting_for_input() {
SessionState::Input
} else {
SessionState::Done
}
}
}
}
pub(crate) fn status_file_path(&self) -> &Path {
&self.status_file
}
pub(crate) fn poll_status_file(&mut self) -> bool {
if self.status_file.as_os_str().is_empty() {
return false;
}
let meta = match std::fs::metadata(&self.status_file) {
Ok(m) => m,
Err(_) => return false,
};
let mtime = meta.modified().ok();
if mtime == self.status_file_mtime {
return false;
}
if let Ok(contents) = std::fs::read_to_string(&self.status_file)
&& let Ok(status) = serde_json::from_str::<ClaudeStatus>(&contents)
{
self.claude_status = Some(status);
self.turn_count += 1;
self.status_file_mtime = mtime;
true
} else {
false
}
}
pub(crate) fn poll_transcript(&mut self) {
use std::io::{Read as _, Seek, SeekFrom};
let transcript_path = match &self.claude_status {
Some(cs) if !cs.transcript_path.is_empty() => cs.transcript_path.clone(),
_ => return,
};
let meta = match std::fs::metadata(&transcript_path) {
Ok(m) => m,
Err(_) => return,
};
let mtime = meta.modified().ok();
if mtime == self.transcript_mtime {
return;
}
self.transcript_mtime = mtime;
let mut file = match std::fs::File::open(&transcript_path) {
Ok(f) => f,
Err(_) => return,
};
let file_len = meta.len();
let tail_size: u64 = 8192;
let mut buf = Vec::new();
if file_len > tail_size {
let _ = file.seek(SeekFrom::End(-(tail_size as i64)));
}
if file.read_to_end(&mut buf).is_err() {
return;
}
let text = String::from_utf8_lossy(&buf);
for line in text.lines().rev() {
if let Some(mode) = crate::types::extract_permission_mode(line) {
self.permission_mode = mode;
break;
}
}
}
pub(crate) fn read_selection_text(
&mut self,
start_row: u16,
start_col: u16,
end_row: u16,
end_col: u16,
scroll_offset: usize,
) -> String {
self.parser.screen_mut().set_scrollback(scroll_offset);
let screen = self.parser.screen();
let text = screen.contents_between(
start_row,
start_col,
end_row,
end_col.saturating_add(1),
);
self.parser.screen_mut().set_scrollback(0);
text.trim_end().to_string()
}
pub(crate) fn read_word_at(
&mut self,
vt_row: u16,
vt_col: u16,
scroll_offset: usize,
) -> Option<(String, u16, u16)> {
self.parser.screen_mut().set_scrollback(scroll_offset);
let screen = self.parser.screen();
let cols = screen.size().1;
let mut row_chars: Vec<char> = Vec::new();
for col in 0..cols {
let cell = screen.cell(vt_row, col);
if let Some(cell) = cell {
let ch: char = cell.contents().chars().next().unwrap_or(' ');
row_chars.push(ch);
} else {
row_chars.push(' ');
}
}
self.parser.screen_mut().set_scrollback(0);
let col = vt_col as usize;
if col >= row_chars.len() {
return None;
}
let is_word_char = |c: char| -> bool { !c.is_whitespace() && !c.is_ascii_punctuation() };
if !is_word_char(row_chars[col]) {
return None;
}
let mut start = col;
while start > 0 && is_word_char(row_chars[start - 1]) {
start -= 1;
}
let mut end = col;
while end + 1 < row_chars.len() && is_word_char(row_chars[end + 1]) {
end += 1;
}
let word: String = row_chars[start..=end].iter().collect();
let word = word.trim().to_string();
if word.is_empty() {
return None;
}
Some((word, start as u16, end as u16))
}
}