Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 5 additions & 6 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![
Expand Down
44 changes: 35 additions & 9 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,21 +371,37 @@ async fn run_server() -> anyhow::Result<()> {
let mut headless = true;
let mut relay_task: Option<tokio::task::JoinHandle<()>> = 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;
Expand Down Expand Up @@ -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;
});
}
});
Expand All @@ -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;
Expand Down
15 changes: 11 additions & 4 deletions src/orchestrator/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,11 @@ pub fn spawn(cfg: OrchestratorConfig, event_tx: mpsc::Sender<AppEvent>) -> 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(),
Expand Down Expand Up @@ -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.",
Expand Down
70 changes: 53 additions & 17 deletions src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -521,30 +538,19 @@ 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)),
}
}

pub fn tokens_display(&self) -> String {
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)
}
}

Expand Down Expand Up @@ -665,6 +671,36 @@ pub fn extract_waiting_prompt(lines: &VecDeque<String>) -> Option<String> {
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([
Expand Down Expand Up @@ -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;
Expand Down
37 changes: 15 additions & 22 deletions src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -545,24 +545,17 @@ fn orchestrator_row(app: &App) -> Option<OrchRow> {
},
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)
Expand Down Expand Up @@ -631,9 +624,9 @@ fn draw_status_panel(f: &mut Frame<'_>, app: &App, area: Rect) -> Vec<Rect> {
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),
];
Expand Down Expand Up @@ -747,10 +740,10 @@ fn draw_status_panel(f: &mut Frame<'_>, app: &App, area: Rect) -> Vec<Rect> {
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("│ "),
Expand Down Expand Up @@ -818,12 +811,12 @@ fn draw_status_panel(f: &mut Frame<'_>, app: &App, area: Rect) -> Vec<Rect> {
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("│ "),
Expand Down
Loading