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
3 changes: 3 additions & 0 deletions crates/merry-cli/src/debug/openai/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,12 @@ async fn tool_helper_executes_one_pending_call_and_continues() {
[
"session_started",
"step_started",
"model_output_rate_updated",
"tool_call_pending",
"artifact_recorded",
"tool_call_resolved",
"step_started",
"model_output_rate_updated",
"assistant_output_recorded",
"step_completed",
]
Expand Down Expand Up @@ -259,6 +261,7 @@ async fn tool_helper_errors_when_first_step_does_not_call_debug_echo() {
[
"session_started",
"step_started",
"model_output_rate_updated",
"assistant_output_recorded",
"step_completed",
]
Expand Down
3 changes: 2 additions & 1 deletion crates/merry-cli/src/runtime_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ mod tests {

let text = String::from_utf8(output).expect("output should be utf-8");
let lines = text.lines().collect::<Vec<_>>();
assert_eq!(lines.len(), 5);
assert_eq!(lines.len(), 6);
let event_types = lines
.iter()
.map(|line| {
Expand All @@ -132,6 +132,7 @@ mod tests {
[
"session_started",
"step_started",
"model_output_rate_updated",
"assistant_output_delta",
"assistant_output_recorded",
"step_completed"
Expand Down
7 changes: 6 additions & 1 deletion crates/merry-cli/src/tui/controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,8 +277,13 @@ pub(crate) async fn run_controller(
};
match message {
InteractiveRunMessage::Event(event) => {
let stream_progress = matches!(event,
merry_core::RuntimeEvent::AssistantMessageDelta { .. }
| merry_core::RuntimeEvent::ModelOutputRateUpdated { .. });
projector.apply(event, &mut state);
render_once(&mut terminal, &mut state)?;
if !stream_progress || !state.is_active_run() {
render_once(&mut terminal, &mut state)?;
}
}
InteractiveRunMessage::ToolInvocations { batch } => {
return Err(unexpected(format!(
Expand Down
1 change: 1 addition & 0 deletions crates/merry-cli/src/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ mod input_history_store;
pub(crate) mod keymap;
mod layout;
mod markdown;
mod output_rate;
mod overlay;
mod overlay_render;
mod plan;
Expand Down
34 changes: 34 additions & 0 deletions crates/merry-cli/src/tui/output_rate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
use merry_core::{ModelOutputRate, RuntimeEvent};

/// Presentation of the last measurable runtime-owned throughput sample.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct OutputRate {
last_measurable_sample: Option<ModelOutputRate>,
}

impl OutputRate {
pub(crate) fn observe(&mut self, event: &RuntimeEvent) {
let RuntimeEvent::ModelOutputRateUpdated {
rate: Some(rate), ..
} = event
else {
return;
};
if rate.tokens_per_second().is_some() || self.last_measurable_sample.is_none() {
self.last_measurable_sample = Some(*rate);
}
}

pub(crate) fn label(&self) -> String {
match self
.last_measurable_sample
.and_then(|sample| sample.tokens_per_second().map(|rate| (sample, rate)))
{
Some((sample, rate)) => {
let prefix = if sample.is_estimated() { "≈" } else { "" };
format!("{prefix}{rate:.1} tok/s")
}
None => "- tok/s".to_owned(),
}
}
}
1 change: 1 addition & 0 deletions crates/merry-cli/src/tui/projector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ impl TuiProjector {
}

pub(crate) fn apply(&mut self, event: RuntimeEvent, state: &mut TuiState) {
state.observe_output_rate(&event);
match event {
RuntimeEvent::AssistantMessage { text, .. } => {
if let Some(index) = self.streaming_assistant_index.take() {
Expand Down
21 changes: 18 additions & 3 deletions crates/merry-cli/src/tui/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ use crate::tui::{
completion::{CompletionMenu, CompletionSources},
input::{DraftImage, InputHistory, TextInput, TuiSubmission},
keymap::Keymap,
output_rate::OutputRate,
plan::PlanUiState,
preferences::{TuiPreferences, TuiSettingsDefaults},
status::{format_header_status_parts, format_session_usage_full},
text_interaction::TextSelection,
theme::TuiTheme,
};
use clipboard::ClipboardFeedback;
use merry_core::{InteractiveRunState, QueuedInputLane, SessionUsage};
use merry_core::{InteractiveRunState, QueuedInputLane, RuntimeEvent, SessionUsage};
use merry_runtime::SkillMetadata;
use overlays::OverlayState;
use std::{
Expand Down Expand Up @@ -61,6 +62,7 @@ pub(crate) struct TuiState {
last_completed_run_elapsed: Option<Duration>,
pending_empty_input_quit: bool,
usage: Option<SessionUsage>,
output_rate: OutputRate,
overlays: OverlayState,
preferences: TuiPreferences,
settings_defaults: TuiSettingsDefaults,
Expand Down Expand Up @@ -119,6 +121,7 @@ impl TuiState {
last_completed_run_elapsed: None,
pending_empty_input_quit: false,
usage: None,
output_rate: OutputRate::default(),
overlays: OverlayState::default(),
preferences: TuiPreferences::default(),
settings_defaults: TuiSettingsDefaults::default(),
Expand Down Expand Up @@ -604,6 +607,10 @@ impl TuiState {
self.usage = Some(usage);
}

pub(crate) fn observe_output_rate(&mut self, event: &RuntimeEvent) {
self.output_rate.observe(event);
}

pub(crate) fn set_reasoning_effort_label(&mut self, label: Option<String>) {
self.reasoning_effort_label = label;
}
Expand Down Expand Up @@ -674,14 +681,22 @@ impl TuiState {
}

pub(crate) fn status_parts(&self) -> [String; 3] {
let usage = format_session_usage_full(self.usage.as_ref());
let rate = self.output_rate.label();
let usage = format_session_usage_full(self.usage.as_ref(), &rate);
let model = self.model_status_label();
[self.workspace_root.display().to_string(), model, usage]
}

pub(crate) fn header_status_parts(&self, width: u16) -> [String; 3] {
let model = self.model_status_label();
format_header_status_parts(&self.workspace_root, &model, self.usage.as_ref(), width)
let rate = self.output_rate.label();
format_header_status_parts(
&self.workspace_root,
&model,
self.usage.as_ref(),
&rate,
width,
)
}

pub(crate) fn interaction_status_text(&self) -> String {
Expand Down
81 changes: 40 additions & 41 deletions crates/merry-cli/src/tui/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,24 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
const BRAND_AND_SEPARATORS_WIDTH: usize = 11;
const MIN_WORKSPACE_WIDTH: usize = 8;

pub(crate) fn format_session_usage_full(usage: Option<&SessionUsage>) -> String {
pub(crate) fn format_session_usage_full(usage: Option<&SessionUsage>, rate: &str) -> String {
usage
.map(format_session_usage)
.unwrap_or_else(SessionUsageDisplay::unavailable)
.map(|usage| format_session_usage(usage, rate))
.unwrap_or_else(|| SessionUsageDisplay::unavailable(rate))
.full
}

pub(crate) fn format_header_status_parts(
workspace: &Path,
model: &str,
usage: Option<&SessionUsage>,
rate: &str,
width: u16,
) -> [String; 3] {
let workspace = workspace.display().to_string();
let usage = usage
.map(format_session_usage)
.unwrap_or_else(SessionUsageDisplay::unavailable);
.map(|usage| format_session_usage(usage, rate))
.unwrap_or_else(|| SessionUsageDisplay::unavailable(rate));
let width = usize::from(width);
let minimum_workspace_width = display_width(&workspace).min(MIN_WORKSPACE_WIDTH);
let model_width = display_width(model);
Expand All @@ -34,7 +35,7 @@ pub(crate) fn format_header_status_parts(
+ display_width(candidate)
<= width
})
.unwrap_or(usage.compact.as_str())
.unwrap_or(usage.minimal.as_str())
.to_owned();

let remaining = width.saturating_sub(BRAND_AND_SEPARATORS_WIDTH + display_width(&usage));
Expand All @@ -53,14 +54,16 @@ struct SessionUsageDisplay {
full: String,
medium: String,
compact: String,
minimal: String,
}

impl SessionUsageDisplay {
fn unavailable() -> Self {
fn unavailable(rate: &str) -> Self {
Self {
full: "usage -".to_owned(),
medium: "usage -".to_owned(),
compact: "usage -".to_owned(),
full: format!("usage - · {rate}"),
medium: format!("usage - · {rate}"),
compact: format!("usage - · {rate}"),
minimal: "usage -".to_owned(),
}
}

Expand All @@ -69,19 +72,22 @@ impl SessionUsageDisplay {
self.full.as_str(),
self.medium.as_str(),
self.compact.as_str(),
self.minimal.as_str(),
]
.into_iter()
}
}

fn format_session_usage(usage: &SessionUsage) -> SessionUsageDisplay {
let compact = format_context_pressure(usage);
fn format_session_usage(usage: &SessionUsage, rate: &str) -> SessionUsageDisplay {
let minimal = format_context_pressure(usage);
let compact = format!("{minimal} · {rate}");
let cache = format_cache_ratio(usage.last.input_tokens(), usage.last.cached_input_tokens());
let medium = cache
.as_ref()
.map_or_else(|| compact.clone(), |cache| format!("{compact} · {cache}"));
let medium = cache.as_ref().map_or_else(
|| compact.clone(),
|cache| format!("{minimal} · {cache} · {rate}"),
);

let mut context_parts = vec![compact.clone()];
let mut context_parts = vec![minimal.clone()];
if let Some(context) = usage.context {
context_parts.push(format!(
"win {} {}",
Expand All @@ -93,7 +99,7 @@ fn format_session_usage(usage: &SessionUsage) -> SessionUsageDisplay {
context_parts.push(cache);
}
let full = format!(
"{} | last in {} out {} | total {} tok",
"{} | last in {} out {} | total {} tok | {rate}",
context_parts.join(" · "),
format_token_count(usage.last.input_tokens()),
format_token_count(usage.last.output_tokens()),
Expand All @@ -104,35 +110,28 @@ fn format_session_usage(usage: &SessionUsage) -> SessionUsageDisplay {
full,
medium,
compact,
minimal,
}
}

fn format_context_pressure(usage: &SessionUsage) -> String {
let Some(compaction) = usage.compaction else {
return usage.context.map_or_else(
|| "ctx -".to_owned(),
|context| {
format!(
"ctx in {}/{}",
format_token_count(usage.last.input_tokens()),
format_token_count(context.resolved_model_window_tokens)
)
},
);
};

let current = compaction
.dynamic_body_estimated_tokens
.map(format_token_count)
.unwrap_or_else(|| "-".to_owned());
if compaction.auto_compaction_enabled {
format!(
"ctx {current}/{}",
format_token_count(compaction.hard_water_tokens)
)
} else {
format!("ctx {current} · compact off")
let mut context = usage.context.map_or_else(
|| "ctx -".to_owned(),
|context| {
format!(
"ctx {}/{}",
format_token_count(usage.last.input_tokens()),
format_token_count(context.effective_window_tokens)
)
},
);
if usage
.compaction
.is_some_and(|compaction| !compaction.auto_compaction_enabled)
{
context.push_str(" · compact off");
}
context
}

fn format_cache_ratio(input_tokens: u64, cached_input_tokens: Option<u64>) -> Option<String> {
Expand Down
1 change: 1 addition & 0 deletions crates/merry-cli/src/tui/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ mod input_controls;
mod layout;

mod output_preview;
mod output_rate;

mod patch_projection;

Expand Down
Loading
Loading