rss attach: mini TUI with fixed input line, utf-8, history, resize handling

This commit is contained in:
Niko Marmeladkov 2026-07-15 22:11:52 +03:00
parent e4aea785ee
commit 4ff0e65e91
Signed by untrusted user who does not match committer: Niko
GPG key ID: E3B955F9442D44E3
4 changed files with 659 additions and 58 deletions

2
Cargo.lock generated
View file

@ -342,7 +342,7 @@ dependencies = [
[[package]]
name = "rss"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"chrono",
"clap",

View file

@ -8,5 +8,5 @@ clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
toml = "0.8"
chrono = "0.4"
nix = { version = "0.29", default-features = false, features = ["signal", "fs"] }
nix = { version = "0.29", default-features = false, features = ["signal", "fs", "term"] }
libc = "0.2"

655
src/attach.rs Normal file
View file

@ -0,0 +1,655 @@
use std::collections::VecDeque;
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::os::unix::net::UnixStream;
use std::sync::mpsc;
use std::time::Duration;
use nix::libc;
const RED: &str = "\x1b[31m";
const RESET: &str = "\x1b[0m";
const GRAY_BG: &str = "\x1b[48;5;236m";
const REVERSE: &str = "\x1b[7m";
const SHOW_CURSOR: &str = "\x1b[?25h";
struct TerminalGuard {
orig: nix::sys::termios::Termios,
stdin: std::io::Stdin,
}
impl TerminalGuard {
fn new() -> Self {
let stdin = std::io::stdin();
let orig = set_raw_mode(&stdin);
TerminalGuard { orig, stdin }
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = write!(std::io::stdout(), "{}\r\n", SHOW_CURSOR);
let _ = std::io::stdout().flush();
let _ = nix::sys::termios::tcsetattr(
&self.stdin,
nix::sys::termios::SetArg::TCSANOW,
&self.orig,
);
}
}
fn set_raw_mode(stdin: &std::io::Stdin) -> nix::sys::termios::Termios {
let mut termios = nix::sys::termios::tcgetattr(stdin).expect("tcgetattr");
let orig = termios.clone();
termios.local_flags.remove(
nix::sys::termios::LocalFlags::ECHO
| nix::sys::termios::LocalFlags::ICANON
| nix::sys::termios::LocalFlags::ISIG
| nix::sys::termios::LocalFlags::IEXTEN,
);
termios.input_flags.remove(
nix::sys::termios::InputFlags::IXON
| nix::sys::termios::InputFlags::BRKINT
| nix::sys::termios::InputFlags::INPCK
| nix::sys::termios::InputFlags::ISTRIP
| nix::sys::termios::InputFlags::ICRNL,
);
termios.output_flags.remove(nix::sys::termios::OutputFlags::OPOST);
termios.control_flags.insert(nix::sys::termios::ControlFlags::CS8);
nix::sys::termios::tcsetattr(stdin, nix::sys::termios::SetArg::TCSANOW, &termios)
.expect("tcsetattr");
orig
}
fn terminal_size() -> (usize, usize) {
unsafe {
let mut ws: libc::winsize = std::mem::zeroed();
if libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) == 0 {
(ws.ws_row as usize, ws.ws_col as usize)
} else {
(24, 80)
}
}
}
fn safe_truncate(s: &str, max: usize) -> &str {
if s.len() <= max {
return s;
}
// Find the nearest char boundary at or before max
let mut idx = max;
while idx > 0 && !s.is_char_boundary(idx) {
idx -= 1;
}
&s[..idx]
}
fn colorize(line: &str) -> String {
let use_color =
std::env::var("NO_COLOR").is_err() && nix::unistd::isatty(1).unwrap_or(false);
if !use_color {
return line.to_string();
}
if line.contains("E: ") || line.starts_with("E:") {
format!("{}{}{}", RED, line, RESET)
} else {
line.to_string()
}
}
fn follow_log(path: &str, tx: mpsc::Sender<String>) {
let file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return,
};
let mut reader = BufReader::new(file);
let _ = reader.seek(SeekFrom::End(0));
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => {
std::thread::sleep(Duration::from_millis(100));
}
Ok(_) => {
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r').to_string();
if !trimmed.is_empty() && tx.send(trimmed).is_err() {
break;
}
}
Err(_) => break,
}
}
}
fn adjust_scroll(input: &str, cursor: usize, scroll: &mut usize, cols: usize) {
let max_visible = cols.saturating_sub(2);
if max_visible == 0 {
return;
}
let threshold = (max_visible as f64 * 0.9) as usize;
if cursor > *scroll + threshold {
*scroll = cursor - threshold;
}
if cursor < *scroll {
*scroll = cursor;
}
if *scroll + max_visible > input.len() {
*scroll = input.len().saturating_sub(max_visible);
}
}
#[allow(clippy::too_many_arguments)]
fn draw_all(
stdout: &mut std::io::Stdout,
rows: usize,
cols: usize,
log_lines: &VecDeque<String>,
input: &str,
cursor: usize,
scroll: usize,
name: &str,
started: bool,
) {
let mut out = String::new();
if !started {
out.push_str("\x1b[2J");
}
let header = format!(" Attached to {} - Ctrl+D detach | Ctrl+C quit ", name);
out.push_str("\x1b[1;1H");
out.push_str(REVERSE);
out.push_str(&header);
out.push_str("\x1b[K");
out.push_str(RESET);
let max_log_rows = rows.saturating_sub(2);
for i in 0..max_log_rows {
out.push_str(&format!("\x1b[{};1H", i + 2));
out.push_str("\x1b[2K");
if let Some(line) = log_lines.get(i) {
out.push_str(safe_truncate(line, cols));
}
}
let prompt = "> ";
let max_visible = cols.saturating_sub(prompt.len());
let visible_start = scroll;
let visible_end = (scroll + max_visible).min(input.len());
let visible = if scroll < input.len() {
&input[visible_start..visible_end]
} else {
""
};
out.push_str(&format!("\x1b[{};1H", rows));
out.push_str(GRAY_BG);
out.push_str(prompt);
out.push_str(visible);
out.push_str("\x1b[K");
out.push_str(RESET);
let cursor_col = prompt.len() + cursor.saturating_sub(scroll);
out.push_str(&format!("\x1b[{};{}H", rows, cursor_col + 1));
let _ = stdout.write_all(out.as_bytes());
let _ = stdout.flush();
}
enum InputAction {
Send,
Exit,
SignalChild,
HistoryPrev,
HistoryNext,
None,
}
#[derive(PartialEq)]
enum EscState {
None,
Escaped,
Bracket,
}
fn utf8_char_len(first: u8) -> Option<usize> {
if first & 0x80 == 0 {
Some(1)
} else if first & 0xE0 == 0xC0 {
Some(2)
} else if first & 0xF0 == 0xE0 {
Some(3)
} else if first & 0xF8 == 0xF0 {
Some(4)
} else {
None
}
}
pub fn run_attach(name: &str, full: bool, stream: UnixStream) {
if !nix::unistd::isatty(0).unwrap_or(false) {
run_attach_simple(name, full, stream);
return;
}
let _guard = TerminalGuard::new();
let mut stdout = std::io::stdout();
let (stdin_tx, stdin_rx) = mpsc::channel();
std::thread::spawn(move || {
let mut buf = [0u8; 1];
loop {
match std::io::stdin().read(&mut buf) {
Ok(0) => {
let _ = stdin_tx.send(None);
break;
}
Ok(_) => {
let _ = stdin_tx.send(Some(buf[0]));
}
Err(_) => break,
}
}
});
let (log_tx, log_rx) = mpsc::channel();
let log_path = crate::state::log_path(name);
if log_path.exists() {
let path_str = log_path.to_string_lossy().to_string();
std::thread::spawn(move || {
follow_log(&path_str, log_tx);
});
}
let mut stream = stream;
let mut input = String::new();
let mut cursor = 0usize;
let mut scroll = 0usize;
let mut log_lines: VecDeque<String> = VecDeque::new();
let mut history: VecDeque<String> = VecDeque::new();
let mut history_idx: Option<usize> = None;
let mut started = false;
let mut utf8_buf: Vec<u8> = Vec::new();
let mut utf8_expected: Option<usize> = None;
let mut esc_state = EscState::None;
let mut prev_rows = 0usize;
let mut prev_cols = 0usize;
loop {
let (rows, cols) = terminal_size();
if rows != prev_rows || cols != prev_cols {
prev_rows = rows;
prev_cols = cols;
started = false;
}
let mut changed = false;
loop {
match stdin_rx.try_recv() {
Ok(byte_opt) => {
changed = true;
match byte_opt {
None => {
let _ = writeln!(std::io::stdout(), "\r");
return;
}
Some(b) => {
let action = {
if b == 0x1b {
esc_state = EscState::Escaped;
changed = true;
continue;
}
match esc_state {
EscState::Escaped => {
esc_state = if b == b'[' {
EscState::Bracket
} else {
EscState::None
};
if esc_state == EscState::Bracket {
InputAction::None
} else {
process_input_byte(
b,
&mut input,
&mut cursor,
&mut scroll,
cols,
&mut utf8_buf,
&mut utf8_expected,
)
}
}
EscState::Bracket => {
esc_state = EscState::None;
process_escape(
b,
&mut input,
&mut cursor,
&mut scroll,
cols,
&mut history,
&mut history_idx,
)
}
EscState::None => process_input_byte(
b,
&mut input,
&mut cursor,
&mut scroll,
cols,
&mut utf8_buf,
&mut utf8_expected,
),
}
};
match action {
InputAction::Send => {
if !input.is_empty() {
let _ = writeln!(stream, "{}", input);
let _ = stream.flush();
history.push_back(input.clone());
if history.len() > 100 {
history.pop_front();
}
history_idx = None;
}
input.clear();
cursor = 0;
scroll = 0;
}
InputAction::Exit => {
let _ = writeln!(std::io::stdout(), "\r");
return;
}
InputAction::SignalChild => {
if full {
let _ =
crate::ipc::send_cmd(&format!("signal {} INT", name));
}
let _ = writeln!(std::io::stdout(), "\r");
return;
}
InputAction::HistoryPrev => {
if history.is_empty() {
continue;
}
if history_idx.is_none() {
history_idx = Some(history.len() - 1);
} else if history_idx.unwrap() > 0 {
history_idx = Some(history_idx.unwrap() - 1);
}
if let Some(idx) = history_idx {
input = history[idx].clone();
cursor = input.len();
scroll = input.len().saturating_sub(cols.saturating_sub(2));
}
adjust_scroll(&input, cursor, &mut scroll, cols);
}
InputAction::HistoryNext => {
if let Some(idx) = history_idx {
if idx + 1 < history.len() {
history_idx = Some(idx + 1);
input = history[idx + 1].clone();
cursor = input.len();
} else {
history_idx = None;
input.clear();
cursor = 0;
}
adjust_scroll(&input, cursor, &mut scroll, cols);
}
}
InputAction::None => {}
}
}
}
}
Err(mpsc::TryRecvError::Disconnected) => {
let _ = writeln!(std::io::stdout(), "\r");
return;
}
Err(mpsc::TryRecvError::Empty) => break,
}
}
while let Ok(line) = log_rx.try_recv() {
changed = true;
let max_log_area = rows.saturating_sub(2);
if log_lines.len() >= max_log_area {
log_lines.pop_front();
}
log_lines.push_back(colorize(&line));
}
if changed || !started {
draw_all(
&mut stdout,
rows,
cols,
&log_lines,
&input,
cursor,
scroll,
name,
started,
);
started = true;
}
std::thread::sleep(Duration::from_millis(16));
}
}
fn process_input_byte(
b: u8,
input: &mut String,
cursor: &mut usize,
scroll: &mut usize,
cols: usize,
utf8_buf: &mut Vec<u8>,
utf8_expected: &mut Option<usize>,
) -> InputAction {
match b {
0x0d | 0x0a => return InputAction::Send,
0x03 => return InputAction::SignalChild,
0x04 => return InputAction::Exit,
0x10 => return InputAction::HistoryPrev,
0x0e => return InputAction::HistoryNext,
0x08 | 0x7f => {
if *cursor > 0 {
*cursor -= 1;
input.remove(*cursor);
}
adjust_scroll(input, *cursor, scroll, cols);
return InputAction::None;
}
0x01 => {
*cursor = 0;
*scroll = 0;
return InputAction::None;
}
0x05 => {
*cursor = input.len();
adjust_scroll(input, *cursor, scroll, cols);
return InputAction::None;
}
0x15 => {
input.clear();
*cursor = 0;
*scroll = 0;
return InputAction::None;
}
0x0b => {
input.truncate(*cursor);
return InputAction::None;
}
_ => {}
}
if b & 0x80 != 0 {
if utf8_buf.is_empty() {
if let Some(len) = utf8_char_len(b) {
utf8_buf.push(b);
*utf8_expected = Some(len);
}
} else if b & 0xC0 == 0x80 {
utf8_buf.push(b);
if Some(utf8_buf.len()) == *utf8_expected {
if let Ok(s) = std::str::from_utf8(&utf8_buf) {
for c in s.chars() {
let n = c.len_utf8();
input.insert(*cursor, c);
*cursor += n;
}
adjust_scroll(input, *cursor, scroll, cols);
}
utf8_buf.clear();
*utf8_expected = None;
}
} else {
utf8_buf.clear();
*utf8_expected = None;
}
} else if b >= 0x20 {
input.insert(*cursor, b as char);
*cursor += 1;
adjust_scroll(input, *cursor, scroll, cols);
}
InputAction::None
}
fn process_escape(
b: u8,
input: &mut String,
cursor: &mut usize,
scroll: &mut usize,
cols: usize,
history: &mut VecDeque<String>,
history_idx: &mut Option<usize>,
) -> InputAction {
match b {
b'A' => {
// Up arrow - history prev
if history.is_empty() {
return InputAction::None;
}
if history_idx.is_none() {
*history_idx = Some(history.len() - 1);
} else if history_idx.unwrap() > 0 {
*history_idx = Some(history_idx.unwrap() - 1);
}
if let Some(idx) = *history_idx {
*input = history[idx].clone();
*cursor = input.len();
}
adjust_scroll(input, *cursor, scroll, cols);
InputAction::None
}
b'B' => {
// Down arrow - history next
if let Some(idx) = *history_idx {
if idx + 1 < history.len() {
*history_idx = Some(idx + 1);
*input = history[idx + 1].clone();
*cursor = input.len();
} else {
*history_idx = None;
input.clear();
*cursor = 0;
}
adjust_scroll(input, *cursor, scroll, cols);
}
InputAction::None
}
b'C' => {
// Right arrow
if *cursor < input.len() {
*cursor += 1;
}
adjust_scroll(input, *cursor, scroll, cols);
InputAction::None
}
b'D' => {
// Left arrow
*cursor = cursor.saturating_sub(1);
adjust_scroll(input, *cursor, scroll, cols);
InputAction::None
}
b'H' => {
// Home
*cursor = 0;
*scroll = 0;
InputAction::None
}
b'F' => {
// End
*cursor = input.len();
adjust_scroll(input, *cursor, scroll, cols);
InputAction::None
}
_ => InputAction::None,
}
}
fn run_attach_simple(name: &str, full: bool, stream: UnixStream) {
use std::sync::atomic::{AtomicBool, Ordering};
static ATTACH_INTERRUPT: AtomicBool = AtomicBool::new(false);
extern "C" fn handle_attach_int(_: i32) {
ATTACH_INTERRUPT.store(true, Ordering::Relaxed);
}
let sig_action = nix::sys::signal::SigAction::new(
nix::sys::signal::SigHandler::Handler(handle_attach_int),
nix::sys::signal::SaFlags::empty(),
nix::sys::signal::SigSet::empty(),
);
unsafe {
let _ = nix::sys::signal::sigaction(nix::sys::signal::Signal::SIGINT, &sig_action);
}
let log_path = crate::state::log_path(name);
if log_path.exists() {
let log_path_str = log_path.to_string_lossy().to_string();
std::thread::spawn(move || {
let _ = crate::log::show_logs(&log_path_str, Some(0), true);
});
}
let stdin = std::io::stdin();
loop {
if ATTACH_INTERRUPT.load(Ordering::Relaxed) {
if full {
let _ = crate::ipc::send_cmd(&format!("signal {} INT", name));
}
break;
}
let mut line = String::new();
match stdin.read_line(&mut line) {
Ok(0) => break,
Ok(_) => {
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
let bytes = trimmed.as_bytes();
if bytes.len() >= 2 && bytes[0] == 0x10 && (bytes[1] == b'd' || bytes[1] == b'D') {
break;
}
if !trimmed.is_empty() {
let _ = writeln!(&stream, "{}", trimmed);
let _ = (&stream).flush();
}
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
}

View file

@ -1,3 +1,4 @@
mod attach;
mod cli;
mod config;
mod daemon;
@ -8,8 +9,6 @@ mod supervisor;
use clap::Parser;
use cli::{Cli, Commands};
use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering};
fn main() {
@ -201,22 +200,7 @@ fn cmd_send(name: &str, message: &[String]) {
}
}
static ATTACH_INTERRUPT: AtomicBool = AtomicBool::new(false);
extern "C" fn handle_attach_int(_: i32) {
ATTACH_INTERRUPT.store(true, Ordering::Relaxed);
}
fn cmd_attach(name: &str, full: bool) {
let sig_action = nix::sys::signal::SigAction::new(
nix::sys::signal::SigHandler::Handler(handle_attach_int),
nix::sys::signal::SaFlags::empty(),
nix::sys::signal::SigSet::empty(),
);
unsafe {
let _ = nix::sys::signal::sigaction(nix::sys::signal::Signal::SIGINT, &sig_action);
}
let stream = match ipc::send_cmd_get_stream(&format!("stdin {}", name)) {
Ok(s) => s,
Err(e) => {
@ -225,43 +209,5 @@ fn cmd_attach(name: &str, full: bool) {
}
};
// Tail the log file in a background thread
let log_path = crate::state::log_path(name);
if log_path.exists() {
let log_path_str = log_path.to_string_lossy().to_string();
std::thread::spawn(move || {
let _ = crate::log::show_logs(&log_path_str, Some(0), true);
});
}
// Main loop: read stdin line by line, send to daemon
let stdin = std::io::stdin();
loop {
if ATTACH_INTERRUPT.load(Ordering::Relaxed) {
if full {
let _ = ipc::send_cmd(&format!("signal {} INT", name));
}
break;
}
let mut line = String::new();
match stdin.read_line(&mut line) {
Ok(0) => break,
Ok(_) => {
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
let bytes = trimmed.as_bytes();
if bytes.len() >= 2 && bytes[0] == 0x10 && (bytes[1] == b'd' || bytes[1] == b'D')
{
break;
}
if !trimmed.is_empty() {
let _ = writeln!(&stream, "{}", trimmed);
let _ = (&stream).flush();
}
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {
continue;
}
Err(_) => break,
}
}
attach::run_attach(name, full, stream);
}