fix(tui): responsive input, request timeouts, poisoned-stream reconnect
R1 — input latency: the event loop drained input once per iteration, then slept the whole metrics interval; keys and wheel events queued for up to 500ms (or the full interval at slower rates) and applied in bursts. The input block is extracted to drain_input() and the tail sleep replaced by a deadline wait in <=33ms poll slices that handles and repaints input the moment it arrives. Verified: help modal opens <150ms into a 2000ms tick. R2 — freeze-proofing: metrics/processes/disks requests had no timeout; a half-dead connection left ws.next() pending forever and froze the TUI with no way to quit (raw mode eats Ctrl+C as an unread key event). All requests now carry a 5s budget. C3 — desync: replies are matched to requests by order alone, so a timed- out request's late reply would shift every subsequent reply off by one. Any timeout now treats the stream as poisoned and goes through the reconnect flow — a fresh stream is aligned by construction. The modal endpoints additionally mark process details unsupported (flag resets on modal close/selection change) so a detail-less agent doesn't cause a reconnect loop. While disconnected the fetch path idles: recovery belongs to the manual/auto retry paths instead of 5s-timeout hammering. C7 — fit::truncate_middle_cols replaces util::truncate_middle: display- width aware and char-boundary safe; the byte-slicing version panicked the draw loop on non-ASCII device names. Verified live: agent kill -9 mid-session -> error modal in <3s, q exits while disconnected, r reconnects and resumes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+588
-465
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
//! Disk cards with per-device gauge and title line.
|
||||
|
||||
use crate::types::Metrics;
|
||||
use crate::ui::util::{disk_icon, human, truncate_middle};
|
||||
use crate::ui::fit::truncate_middle_cols;
|
||||
use crate::ui::util::{disk_icon, human};
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::Style,
|
||||
@@ -69,7 +70,7 @@ pub fn draw_disks(f: &mut ratatui::Frame<'_>, area: Rect, m: Option<&Metrics>) {
|
||||
"{}{}{}{} {} / {} ({}%)",
|
||||
indent,
|
||||
disk_icon(&d.name),
|
||||
truncate_middle(&d.name, (slot.width.saturating_sub(6)) as usize / 2),
|
||||
truncate_middle_cols(&d.name, slot.width.saturating_sub(6) / 2),
|
||||
temp_str,
|
||||
human(used),
|
||||
human(d.total),
|
||||
|
||||
@@ -43,6 +43,46 @@ pub fn truncate_cols(s: &str, max: u16) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Shortens `s` to at most `max` columns by cutting the MIDDLE, marking the
|
||||
/// cut with `…` — device names like `/dev/nvme0n1p1` keep their distinctive
|
||||
/// prefix and suffix. Column- and char-boundary-safe; the byte-slicing
|
||||
/// predecessor in `util.rs` panicked on non-ASCII names.
|
||||
pub fn truncate_middle_cols(s: &str, max: u16) -> String {
|
||||
if cols(s) <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
if max <= 1 {
|
||||
return truncate_cols(s, max);
|
||||
}
|
||||
// Reserve one column for the ellipsis; split the rest left/right.
|
||||
let left_budget = (max - 1) / 2;
|
||||
let right_budget = max - 1 - left_budget;
|
||||
|
||||
let mut left_end = 0; // byte index
|
||||
let mut used = 0u16;
|
||||
for (i, ch) in s.char_indices() {
|
||||
let w = cols(ch.encode_utf8(&mut [0u8; 4]));
|
||||
if used + w > left_budget {
|
||||
break;
|
||||
}
|
||||
used += w;
|
||||
left_end = i + ch.len_utf8();
|
||||
}
|
||||
|
||||
let mut right_start = s.len();
|
||||
let mut used = 0u16;
|
||||
for (i, ch) in s.char_indices().rev() {
|
||||
let w = cols(ch.encode_utf8(&mut [0u8; 4]));
|
||||
if used + w > right_budget || i < left_end {
|
||||
break;
|
||||
}
|
||||
used += w;
|
||||
right_start = i;
|
||||
}
|
||||
|
||||
format!("{}…{}", &s[..left_end], &s[right_start..])
|
||||
}
|
||||
|
||||
/// Picks the first (richest) candidate pair that fits side by side in `width` columns
|
||||
/// with at least `gap` columns between them.
|
||||
///
|
||||
@@ -108,6 +148,23 @@ mod tests {
|
||||
assert_eq!(truncate_cols("🔒ab", 2), "…");
|
||||
}
|
||||
|
||||
/// Middle truncation keeps both ends — the parts that identify a device —
|
||||
/// and must never exceed the budget or split a character.
|
||||
#[test]
|
||||
fn truncate_middle_keeps_both_ends_within_budget() {
|
||||
assert_eq!(truncate_middle_cols("/dev/nvme0n1p1", 20), "/dev/nvme0n1p1");
|
||||
let out = truncate_middle_cols("/dev/nvme0n1p1", 9);
|
||||
assert_eq!(cols(&out), 9);
|
||||
assert!(out.starts_with("/dev"), "{out}");
|
||||
assert!(out.ends_with("1p1"), "{out}");
|
||||
assert!(out.contains('…'), "{out}");
|
||||
// Non-ASCII names must not panic (the old byte-slicing version did).
|
||||
for max in 0..12u16 {
|
||||
let out = truncate_middle_cols("диск-🗄️-данные", max);
|
||||
assert!(cols(&out) <= max.max(1), "{out:?} exceeds {max}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_pair_takes_the_richest_that_fits() {
|
||||
let candidates = [
|
||||
|
||||
@@ -22,19 +22,6 @@ pub fn human(b: u64) -> String {
|
||||
format!("{tb:.2}TB")
|
||||
}
|
||||
|
||||
pub fn truncate_middle(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
if max <= 3 {
|
||||
return "...".into();
|
||||
}
|
||||
let keep = max - 3;
|
||||
let left = keep / 2;
|
||||
let right = keep - left;
|
||||
format!("{}...{}", &s[..left], &s[s.len() - right..])
|
||||
}
|
||||
|
||||
pub fn disk_icon(name: &str) -> &'static str {
|
||||
let n = name.to_ascii_lowercase();
|
||||
if n.contains(':') {
|
||||
|
||||
@@ -8,6 +8,7 @@ static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
#[allow(dead_code)] // touch crate
|
||||
fn touch() {
|
||||
let _ = socktop::types::Metrics {
|
||||
sampled_at_ms: None,
|
||||
cpu_total: 0.0,
|
||||
cpu_per_core: vec![],
|
||||
mem_total: 0,
|
||||
|
||||
Reference in New Issue
Block a user