diff --git a/src/app.rs b/src/app.rs index 57d1393..4bf48b1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7122,8 +7122,12 @@ mod tests { otx, "agent".into(), )); - - // READY is in the default event list (completion notifications) + // Opt into "ready" events for this test (not in the default list) + { + let mut cfg = (*app.config).clone(); + cfg.orchestrator.events.push("ready".into()); + app.config = std::sync::Arc::new(cfg); + } app.notify_orchestrator(watched, &SessionState::Ready); assert!(orx.try_recv().is_ok()); // …but not twice within the cooldown @@ -7155,9 +7159,13 @@ mod tests { otx, "agent".into(), )); + // Opt into "ready" events for this test (not in the default list) + { + let mut cfg = (*app.config).clone(); + cfg.orchestrator.events.push("ready".into()); + app.config = std::sync::Arc::new(cfg); + } - // Simulate an agent CLI that streamed output and then went quiet: - // Running with the last output more than 2s ago. { let s = app.sessions.iter_mut().find(|s| s.id == id).unwrap(); s.state = SessionState::Running; diff --git a/src/config.rs b/src/config.rs index 52d1c0b..d23d49c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -134,12 +134,11 @@ impl Default for OrchestratorConfig { cwd: String::new(), hidden: true, permission_mode: "accept-edits".to_string(), - events: vec![ - "ready".into(), - "waiting".into(), - "error".into(), - "dead".into(), - ], + // "ready" is deliberately absent: sessions going idle is the most + // frequent and least actionable transition, and each event costs + // a full orchestrator turn (expensive on local models). Add + // "ready" to [orchestrator].events to opt back in. + events: vec!["waiting".into(), "error".into(), "dead".into()], event_cooldown_secs: 30, approval: "auto".to_string(), auto_approve: vec![ diff --git a/src/main.rs b/src/main.rs index 6fcf997..848d01e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -371,21 +371,37 @@ async fn run_server() -> anyhow::Result<()> { let mut headless = true; let mut relay_task: Option> = None; let mut relay_kitty = false; + let mut relay_write_failures: u32 = 0; loop { if !headless && app.needs_redraw && last_render.elapsed() >= frame_cap { let mut layout = ui::LayoutInfo::default(); if let Err(error) = terminal.draw(|f| { layout = ui::draw(f, &app); }) { + last_render = Instant::now(); if is_transient_terminal_error(&error) { // A busy terminal or relay socket may briefly reject a - // frame. Keep the redraw pending and retry after the frame - // interval instead of terminating the whole application. - last_render = Instant::now(); - continue; + // frame; retry. But while attached, repeated timeouts + // mean the relay client is wedged (not draining its + // socket) — without escalation the server would render + // one frame per write-timeout forever and reject new + // reattach attempts as "already attached". + relay_write_failures += 1; + if headless || relay_write_failures < 3 { + continue; + } + } else if headless { + // No relay client involved: a real backend failure. + return Err(error.into()); } - return Err(error.into()); + // The attached client is dead or stalled. Drop it and go + // headless instead of hanging or killing the server — the + // sessions keep running and the user can reattach. + let _ = tx.try_send(AppEvent::Detach); + relay_write_failures = 0; + continue; } + relay_write_failures = 0; app.output_areas = layout.output_areas.clone(); app.session_bar_area = layout.session_bar_area; app.session_slot_areas = layout.session_slot_areas; @@ -1386,14 +1402,20 @@ fn spawn_reattach_listener( if stream.write_all(b"{\"ok\":true}\n").await.is_err() { return; } - let _ = tx - .send(AppEvent::Reattach { + // Bounded: if the main loop is wedged and never drains the + // channel, dropping the stream here EOFs the client so it + // exits cleanly instead of hanging on a blank screen after + // the ok-ack. + let _ = tokio::time::timeout( + Duration::from_secs(5), + tx.send(AppEvent::Reattach { stream, rows, cols, kitty, - }) - .await; + }), + ) + .await; }); } }); @@ -1416,6 +1438,10 @@ async fn do_reattach( let Ok(writer_clone) = std_stream.try_clone() else { return None; }; + // A stalled client (not draining its socket) must not block the main + // loop inside terminal.draw() forever: bound each write, and let the + // resulting error surface as a detach in the draw error path. + let _ = writer_clone.set_write_timeout(Some(Duration::from_secs(5))); let _ = std_stream.set_nonblocking(true); // back to non-blocking for tokio let Ok(relay_reader) = tokio::net::UnixStream::from_std(std_stream) else { return None; diff --git a/src/orchestrator/mod.rs b/src/orchestrator/mod.rs index 0aeca83..deae625 100644 --- a/src/orchestrator/mod.rs +++ b/src/orchestrator/mod.rs @@ -214,8 +214,11 @@ pub fn spawn(cfg: OrchestratorConfig, event_tx: mpsc::Sender) -> Orche Err(e) => format!("[{}: error: {}]", cfg.name, e), }; // Always answer a human; stay quiet only if an event turn produced - // nothing (the model may have just filed tool calls / no comment). - if any_user || !text.trim().is_empty() { + // nothing, or the bare `ok` the system prompt designates as the + // "nothing to report" acknowledgment for informational events. + let noop_ack = + text.trim().eq_ignore_ascii_case("ok") || text.trim().eq_ignore_ascii_case("ok."); + if any_user || (!text.trim().is_empty() && !noop_ack) { let _ = event_tx .send(AppEvent::ChatReply { from: cfg.name.clone(), @@ -636,8 +639,12 @@ session's process (SIGSTOP) without losing its context — its state shows PAUSE which is the right lever when concurrent sessions contend for limited CPU or RAM.\n\ \n\ Messages starting with [linkshell event] are automatic notifications that a session \ -changed state; investigate briefly (read_output) and tell the user in one or two \ -sentences what happened, what it needs, and what you suggest. Messages starting \ +changed state. For WAITING, ERROR, and DEAD events: investigate briefly (read_output) \ +and tell the user in one or two sentences what happened, what it needs, and what you \ +suggest. For purely informational events that require no action from you or the user \ +(a session simply became READY/idle and nothing depends on it), do NOT call any tools \ +and reply with exactly `ok` — that reply is suppressed and never shown to the user. \ +Messages starting \ with [linkshell] are system notes.\n\ \n\ Your replies render in a small chat pane: be concise, no markdown headers.", diff --git a/src/session.rs b/src/session.rs index 7bf238b..8ab5f64 100644 --- a/src/session.rs +++ b/src/session.rs @@ -300,6 +300,23 @@ pub struct Session { last_screen_hash: u64, } +/// Compact human count for status columns: 999, 42.3k, 999.9k, 1.23M, 99.9M, +/// 999M. Always ≤ 6 chars. +pub fn fmt_count(n: u64) -> String { + let f = n as f64; + if n < 1_000 { + n.to_string() + } else if f < 999_950.0 { + format!("{:.1}k", f / 1_000.0) + } else if f < 9_995_000.0 { + format!("{:.2}M", f / 1_000_000.0) + } else if f < 99_950_000.0 { + format!("{:.1}M", f / 1_000_000.0) + } else { + format!("{:.0}M", f / 1_000_000.0) + } +} + impl Session { pub fn new( id: usize, @@ -521,19 +538,10 @@ impl Session { } pub fn context_display(&self) -> String { - let ctx = self.stats.context_tokens; - let max = self.context_max; - fn short(n: u64) -> String { - if n >= 1000 { - format!("{:.1}k", n as f64 / 1000.0) - } else { - n.to_string() - } - } - match (ctx, max) { + match (self.stats.context_tokens, self.context_max) { (0, 0) => "—".to_string(), - (c, 0) => format!("{} ctx", short(c)), - (c, m) => format!("{}/{}", short(c), short(m)), + (c, 0) => fmt_count(c), + (c, m) => format!("{}/{}", fmt_count(c), fmt_count(m)), } } @@ -541,10 +549,8 @@ impl Session { let total = self.stats.input_tokens + self.stats.output_tokens; if total == 0 { "—".to_string() - } else if total >= 1000 { - format!("{:.1}k tok", total as f64 / 1000.0) } else { - format!("{} tok", total) + fmt_count(total) } } @@ -665,6 +671,36 @@ pub fn extract_waiting_prompt(lines: &VecDeque) -> Option { mod tests { use super::*; + #[test] + fn fmt_count_tiers_and_width() { + assert_eq!(fmt_count(0), "0"); + assert_eq!(fmt_count(999), "999"); + assert_eq!(fmt_count(1_000), "1.0k"); + assert_eq!(fmt_count(42_340), "42.3k"); + assert_eq!(fmt_count(999_900), "999.9k"); + // The 1000k boundary rolls over to M instead of "1000.0k". + assert_eq!(fmt_count(999_950), "1.00M"); + assert_eq!(fmt_count(1_234_000), "1.23M"); + assert_eq!(fmt_count(12_340_000), "12.3M"); + assert_eq!(fmt_count(123_400_000), "123M"); + for n in [ + 0, + 999, + 1_000, + 999_949, + 999_950, + 9_994_999, + 99_949_999, + u32::MAX as u64, + ] { + assert!( + fmt_count(n).chars().count() <= 6, + "{} too wide", + fmt_count(n) + ); + } + } + #[test] fn waiting_prompt_prefers_recent_question_and_ignores_box_drawing() { let lines = VecDeque::from([ @@ -821,8 +857,8 @@ mod tests { shell.stats.output_tokens = 1; shell.stats.context_tokens = 1250; shell.stats.total_cost_usd = 0.1234; - assert_eq!(shell.tokens_display(), "1.0k tok"); - assert_eq!(shell.context_display(), "1.2k ctx"); + assert_eq!(shell.tokens_display(), "1.0k"); + assert_eq!(shell.context_display(), "1.2k"); assert_eq!(shell.cost_display(), "$0.123"); shell.context_max = 32768; diff --git a/src/ui.rs b/src/ui.rs index 3c0da12..e101d45 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -545,24 +545,17 @@ fn orchestrator_row(app: &App) -> Option { }, tokens: if total == 0 { "—".into() - } else if total >= 1000 { - format!("{:.1}k tok", total as f64 / 1000.0) } else { - format!("{total} tok") + crate::session::fmt_count(total) }, - ctx: { - fn short(n: u64) -> String { - if n >= 1000 { - format!("{:.1}k", n as f64 / 1000.0) - } else { - n.to_string() - } - } - match (stats.context_tokens, app.orchestrator_ctx_max) { - (0, None) => "—".into(), - (c, None) => format!("{} ctx", short(c)), - (c, Some(m)) => format!("{}/{}", short(c), short(m)), - } + ctx: match (stats.context_tokens, app.orchestrator_ctx_max) { + (0, None) => "—".into(), + (c, None) => crate::session::fmt_count(c), + (c, Some(m)) => format!( + "{}/{}", + crate::session::fmt_count(c), + crate::session::fmt_count(m) + ), }, cost: if stats.total_cost_usd > 0.0 { format!("${:.3}", stats.total_cost_usd) @@ -631,9 +624,9 @@ fn draw_status_panel(f: &mut Frame<'_>, app: &App, area: Rect) -> Vec { Span::styled("│ ", hdr_style), Span::styled(format!("{:>6} ", "Time"), hdr_style), Span::styled("│ ", hdr_style), - Span::styled(format!("{:>8} ", "Tokens"), hdr_style), + Span::styled(format!("{:>6} ", "Tokens"), hdr_style), Span::styled("│ ", hdr_style), - Span::styled(format!("{:>8} ", "Ctx"), hdr_style), + Span::styled(format!("{:>13} ", "Ctx"), hdr_style), Span::styled("│ ", hdr_style), Span::styled(format!("{:>7}", "Cost"), hdr_style), ]; @@ -747,10 +740,10 @@ fn draw_status_panel(f: &mut Frame<'_>, app: &App, area: Rect) -> Vec { Style::default().fg(Color::Gray), ), Span::raw("│ "), - Span::styled(format!("{:>8} ", tokens), Style::default().fg(Color::Cyan)), + Span::styled(format!("{:>6} ", tokens), Style::default().fg(Color::Cyan)), Span::raw("│ "), Span::styled( - format!("{:>8} ", context), + format!("{:>13} ", context), Style::default().fg(Color::Magenta), ), Span::raw("│ "), @@ -818,12 +811,12 @@ fn draw_status_panel(f: &mut Frame<'_>, app: &App, area: Rect) -> Vec { Span::styled(format!("{:>6} ", ""), Style::default().fg(Color::Gray)), Span::raw("│ "), Span::styled( - format!("{:>8} ", o.tokens), + format!("{:>6} ", o.tokens), Style::default().fg(Color::Cyan), ), Span::raw("│ "), Span::styled( - format!("{:>8} ", o.ctx), + format!("{:>13} ", o.ctx), Style::default().fg(Color::Magenta), ), Span::raw("│ "),