Compare commits

..
1 Commits
Author SHA1 Message Date
CodeWhale BotandClaude Sonnet 5.5 1f56b9b9cc feat(plugins): one-line install summary builder for plugins and DSH packages (CW-8182)
A pure function, install::build_summary, turns what an install already knows
into the single line a person reads afterward: name, version and source; on or
off; who approved; what it adds; which converted rows will not run and why
(manual ports and rows refused by the host version gate); that native rows need
the extension host while it is off; host services granted, shared, withheld or
not offered, with the pause-until-idle note; and that provider declarations
apply in a new session. An update names only what changed, plus a source
change and an allowed downgrade. Each clause carries a stable message id for
localization; the English text is the fallback. No caller yet: the shared
one-step function and the clients adopt it next.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 17:55:54 -08:00
11 changed files with 867 additions and 2038 deletions

No files matched your search

+2
View File
@@ -86,6 +86,7 @@ use codewhale_skill_install::install::{
pub mod dsh;
mod place;
mod stage;
mod summary;
mod tarball;
#[cfg(test)]
@@ -93,6 +94,7 @@ mod tests;
use place::{ensure_target_within_plugins_dir, finalize_install, plugin_target_path};
use stage::stage_local_copy;
pub use summary::{PluginSummary, PreviousInstall, SummaryClause, SummaryInput, build_summary};
use tarball::stage_tarball;
/// Default per-bundle size cap. Mirrors the skill installer; the runtime
+865
View File
@@ -0,0 +1,865 @@
//! One-line install summary shared by every install surface.
//!
//! A pure builder: everything it needs arrives in [`SummaryInput`], nothing is
//! read from disk, the registry or the environment. Each clause carries a
//! stable message id so a surface can localize the clause while the English
//! text here stays the fallback.
use std::collections::BTreeSet;
use super::dsh::DshConversion;
use crate::host_services::{HostService, HostServiceReview, PluginApprover, approver_label};
use crate::manifest::PluginInventory;
pub const MSG_HEAD: &str = "plugin.summary.head";
pub const MSG_STATE_ON: &str = "plugin.summary.state_on";
pub const MSG_STATE_OFF: &str = "plugin.summary.state_off";
pub const MSG_APPROVER: &str = "plugin.summary.approver";
pub const MSG_ADDS: &str = "plugin.summary.adds";
pub const MSG_ADDS_NOTHING_NEW: &str = "plugin.summary.adds_nothing_new";
pub const MSG_NOW_ALSO_ADDS: &str = "plugin.summary.now_also_adds";
pub const MSG_WONT_RUN: &str = "plugin.summary.wont_run";
pub const MSG_NEEDS_EXTENSION_HOST: &str = "plugin.summary.needs_extension_host";
pub const MSG_NEEDS_NODE: &str = "plugin.summary.needs_node";
pub const MSG_REPLACES: &str = "plugin.summary.replaces";
pub const MSG_DOWNGRADED: &str = "plugin.summary.downgraded";
pub const MSG_SERVICES: &str = "plugin.summary.services";
pub const MSG_SERVICES_NOW_ALSO: &str = "plugin.summary.services_now_also";
pub const MSG_SERVICES_WITHHELD: &str = "plugin.summary.services_withheld";
pub const MSG_SERVICES_UNOFFERED: &str = "plugin.summary.services_unoffered";
pub const MSG_SERVICES_PAUSE: &str = "plugin.summary.services_pause";
pub const MSG_PROVIDER_NEW_SESSION: &str = "plugin.summary.provider_new_session";
const HOST_LIST_LIMIT: usize = 3;
const VERSION_GATE_REASON: &str = "incompatible-version";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SummaryClause {
pub id: &'static str,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginSummary {
pub clauses: Vec<SummaryClause>,
}
impl PluginSummary {
#[must_use]
pub fn line(&self) -> String {
let mut clauses = self.clauses.iter();
let Some(head) = clauses.next() else {
return String::new();
};
let rest: Vec<&str> = clauses.map(|clause| clause.text.as_str()).collect();
if rest.is_empty() {
head.text.clone()
} else {
format!("{}: {}", head.text, rest.join("; "))
}
}
#[must_use]
pub fn ids(&self) -> Vec<&'static str> {
self.clauses.iter().map(|clause| clause.id).collect()
}
}
#[derive(Debug, Clone)]
pub struct PreviousInstall<'a> {
pub name: &'a str,
pub version: &'a str,
pub source: Option<&'a str>,
pub inventory: &'a PluginInventory,
pub host_services: &'a [HostService],
}
#[derive(Debug, Clone)]
pub struct SummaryInput<'a> {
pub name: &'a str,
pub version: &'a str,
pub source: Option<&'a str>,
pub enabled: bool,
pub approver: Option<&'a PluginApprover>,
pub inventory: &'a PluginInventory,
pub dsh: Option<&'a DshConversion>,
pub host_services: Option<&'a HostServiceReview>,
pub extension_host_on: bool,
pub previous: Option<PreviousInstall<'a>>,
pub downgrade_allowed: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct Capabilities {
skills: usize,
local_mcp: usize,
remote_mcp: usize,
commands: usize,
agents: usize,
hooks: usize,
lsp: usize,
native: usize,
providers: usize,
panels: usize,
filesystem_roots: BTreeSet<String>,
network_hosts: BTreeSet<String>,
lifecycle_mutation: bool,
}
impl Capabilities {
fn from_inventory(inventory: &PluginInventory) -> Self {
let split = inventory.stdio_mcp_servers + inventory.remote_mcp_servers;
let (local_mcp, remote_mcp) = if split == 0 {
(inventory.mcp_servers, 0)
} else {
(
inventory.stdio_mcp_servers + inventory.mcp_servers.saturating_sub(split),
inventory.remote_mcp_servers,
)
};
Self {
skills: inventory.skills,
local_mcp,
remote_mcp,
commands: inventory.commands,
agents: inventory.agents,
hooks: inventory.hooks,
lsp: inventory.lsp,
native: inventory.native,
providers: inventory.providers,
panels: 0,
filesystem_roots: inventory.filesystem_roots.iter().cloned().collect(),
network_hosts: inventory.network_hosts.iter().cloned().collect(),
lifecycle_mutation: inventory.lifecycle_mutation,
}
}
fn with_dsh(mut self, dsh: &DshConversion) -> Self {
self.skills = self.skills.max(dsh.skills.len());
self.local_mcp = self.local_mcp.max(dsh.local_servers.len());
self.remote_mcp = self.remote_mcp.max(dsh.remote_servers.len());
self.native = self.native.max(dsh.native_rows.len());
if dsh.requires_native && self.native == 0 {
self.native = 1;
}
self.panels = self.panels.max(dsh.client_closures.len());
self.network_hosts.extend(dsh.network_hosts.iter().cloned());
self
}
fn since(&self, old: &Self) -> Self {
let more = |new: usize, old: usize| new.saturating_sub(old);
Self {
skills: more(self.skills, old.skills),
local_mcp: more(self.local_mcp, old.local_mcp),
remote_mcp: more(self.remote_mcp, old.remote_mcp),
commands: more(self.commands, old.commands),
agents: more(self.agents, old.agents),
hooks: more(self.hooks, old.hooks),
lsp: more(self.lsp, old.lsp),
native: more(self.native, old.native),
providers: more(self.providers, old.providers),
panels: more(self.panels, old.panels),
filesystem_roots: self
.filesystem_roots
.difference(&old.filesystem_roots)
.cloned()
.collect(),
network_hosts: self
.network_hosts
.difference(&old.network_hosts)
.cloned()
.collect(),
lifecycle_mutation: self.lifecycle_mutation && !old.lifecycle_mutation,
}
}
fn phrases(&self) -> Vec<String> {
let mut phrases = Vec::new();
let mut count = |n: usize, one: &str, many: &str| {
if n > 0 {
phrases.push(format!("{n} {}", if n == 1 { one } else { many }));
}
};
count(self.skills, "skill", "skills");
count(self.local_mcp, "local MCP server", "local MCP servers");
count(self.remote_mcp, "remote MCP server", "remote MCP servers");
count(self.commands, "command", "commands");
count(self.agents, "agent profile", "agent profiles");
count(self.hooks, "hook", "hooks");
count(self.lsp, "language server", "language servers");
count(self.native, "native extension", "native extensions");
count(self.providers, "provider", "providers");
count(self.panels, "panel", "panels");
if !self.filesystem_roots.is_empty() {
phrases.push(format!("file access ({})", limited(&self.filesystem_roots)));
}
if !self.network_hosts.is_empty() {
phrases.push(format!("network access ({})", limited(&self.network_hosts)));
}
if self.lifecycle_mutation {
phrases.push("permission to change plugins".to_string());
}
phrases
}
}
fn limited(items: &BTreeSet<String>) -> String {
let shown: Vec<&str> = items
.iter()
.take(HOST_LIST_LIMIT)
.map(String::as_str)
.collect();
let mut text = shown.join(", ");
if items.len() > HOST_LIST_LIMIT {
text.push_str(&format!(" and {} more", items.len() - HOST_LIST_LIMIT));
}
text
}
fn and_list(items: &[String]) -> String {
match items {
[] => String::new(),
[only] => only.clone(),
[init @ .., last] => format!("{} and {last}", init.join(", ")),
}
}
fn rows(n: usize) -> String {
format!("{n} {}", if n == 1 { "row" } else { "rows" })
}
fn quoted(peers: &[crate::host_services::ReviewPeer]) -> String {
peers
.iter()
.map(|peer| format!("`{}`", peer.name))
.collect::<Vec<_>>()
.join(", ")
}
fn push(clauses: &mut Vec<SummaryClause>, id: &'static str, text: String) {
clauses.push(SummaryClause { id, text });
}
#[must_use]
pub fn build_summary(input: &SummaryInput<'_>) -> PluginSummary {
let mut clauses = Vec::new();
let mut head = input.name.to_string();
if !input.version.is_empty() {
head.push(' ');
head.push_str(input.version);
}
if let Some(source) = input.source.filter(|source| !source.is_empty()) {
head.push_str(&format!(" from {source}"));
}
push(&mut clauses, MSG_HEAD, head);
if input.enabled {
push(&mut clauses, MSG_STATE_ON, "on".to_string());
} else {
push(&mut clauses, MSG_STATE_OFF, "off".to_string());
}
push(
&mut clauses,
MSG_APPROVER,
approver_label(input.approver).to_string(),
);
if let Some(previous) = &input.previous {
if input.downgrade_allowed && previous.version != input.version {
push(
&mut clauses,
MSG_DOWNGRADED,
format!("downgraded from {}", previous.version),
);
}
if previous.source != input.source {
let from = previous
.source
.filter(|source| !source.is_empty())
.map_or_else(|| "an unrecorded source".to_string(), str::to_string);
push(
&mut clauses,
MSG_REPLACES,
format!("replaces {} from {from}", previous.name),
);
}
}
let mut now = Capabilities::from_inventory(input.inventory);
if let Some(dsh) = input.dsh {
now = now.with_dsh(dsh);
}
match &input.previous {
None => {
let phrases = now.phrases();
if !phrases.is_empty() {
push(
&mut clauses,
MSG_ADDS,
format!("adds {}", and_list(&phrases)),
);
}
}
Some(previous) => {
let added = now
.since(&Capabilities::from_inventory(previous.inventory))
.phrases();
if added.is_empty() {
push(
&mut clauses,
MSG_ADDS_NOTHING_NEW,
"adds nothing new".to_string(),
);
} else {
push(
&mut clauses,
MSG_NOW_ALSO_ADDS,
format!("now also adds {}", and_list(&added)),
);
}
}
}
if let Some(dsh) = input.dsh {
let skipped: Vec<_> = dsh
.outcomes
.iter()
.filter(|outcome| outcome.needs_manual_port())
.collect();
if !skipped.is_empty() {
let gated = skipped
.iter()
.filter(|outcome| outcome.reason.starts_with(VERSION_GATE_REASON))
.count();
let manual = skipped.len() - gated;
let reason = match (gated, manual) {
(0, _) => "each needs a manual port".to_string(),
(_, 0) => "refused by the host version gate".to_string(),
_ => format!(
"{gated} refused by the host version gate, {manual} with no portable form"
),
};
push(
&mut clauses,
MSG_WONT_RUN,
format!("won't run: {} ({reason})", rows(skipped.len())),
);
}
if dsh.requires_node {
push(
&mut clauses,
MSG_NEEDS_NODE,
"needs Node.js for its local servers".to_string(),
);
}
}
if now.native > 0 && !input.extension_host_on {
push(
&mut clauses,
MSG_NEEDS_EXTENSION_HOST,
"needs the extension host (off)".to_string(),
);
}
if let Some(review) = input.host_services {
let granted = review.grants();
let previously: &[HostService] = input
.previous
.as_ref()
.map_or(&[], |previous| previous.host_services);
let names = |services: Vec<HostService>| -> Vec<String> {
services
.into_iter()
.map(|service| service.name().to_string())
.collect()
};
match &input.previous {
None => {
if !granted.is_empty() {
let mut text =
format!("uses host services {}", and_list(&names(granted.clone())));
let mut peers: Vec<_> = review
.lines
.iter()
.filter(|line| line.offered)
.flat_map(|line| line.shared_with.iter())
.collect();
peers.sort_by(|a, b| a.id.cmp(&b.id));
peers.dedup_by(|a, b| a.id == b.id);
if !peers.is_empty() {
let peers: Vec<_> = peers.into_iter().cloned().collect();
text.push_str(&format!(" (shared with {})", quoted(&peers)));
}
push(&mut clauses, MSG_SERVICES, text);
}
}
Some(_) => {
let fresh: Vec<_> = granted
.iter()
.copied()
.filter(|service| !previously.contains(service))
.collect();
if !fresh.is_empty() {
push(
&mut clauses,
MSG_SERVICES_NOW_ALSO,
format!("now also uses host services {}", and_list(&names(fresh))),
);
}
}
}
let withheld: Vec<String> = review
.lines
.iter()
.filter(|line| line.offered && !line.withheld_by.is_empty())
.map(|line| {
format!(
"{} ({} is turned on without it)",
line.service.name(),
quoted(&line.withheld_by)
)
})
.collect();
if !withheld.is_empty() {
push(
&mut clauses,
MSG_SERVICES_WITHHELD,
format!("off for now: {}", withheld.join(", ")),
);
}
let unoffered: Vec<String> = review
.lines
.iter()
.filter(|line| !line.offered)
.map(|line| line.service.name().to_string())
.collect();
if !unoffered.is_empty() {
push(
&mut clauses,
MSG_SERVICES_UNOFFERED,
format!(
"this Codewhale does not offer {} to plugins",
and_list(&unoffered)
),
);
}
if !granted.is_empty() {
push(
&mut clauses,
MSG_SERVICES_PAUSE,
"services pause for every plugin in its host until the host is idle".to_string(),
);
}
}
if now.providers > 0 {
push(
&mut clauses,
MSG_PROVIDER_NEW_SESSION,
"provider changes apply in a new session".to_string(),
);
}
PluginSummary { clauses }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host_services::{
AgentApproval, AgentPosture, HostServiceLine, PersonVia, PluginCohort, ReviewPeer,
};
use crate::install::dsh::DshOutcome;
fn inventory() -> PluginInventory {
PluginInventory::default()
}
fn input<'a>(inventory: &'a PluginInventory, approver: &'a PluginApprover) -> SummaryInput<'a> {
SummaryInput {
name: "notes",
version: "1.2.0",
source: Some("github:acme/notes"),
enabled: true,
approver: Some(approver),
inventory,
dsh: None,
host_services: None,
extension_host_on: true,
previous: None,
downgrade_allowed: false,
}
}
fn person() -> PluginApprover {
PluginApprover::person(PersonVia::Terminal)
}
fn conversion() -> DshConversion {
DshConversion {
plugin_name: "dsh-whale-widget".into(),
source_package: Some("dsh-whale-widget".into()),
source_version: Some("0.4.2".into()),
manifest_sha256: "0".repeat(64),
layers: Vec::new(),
outcomes: Vec::new(),
diagnostics: Vec::new(),
skills: vec!["balance".into()],
remote_servers: vec!["account".into()],
local_servers: vec!["bridge".into(), "helper".into()],
network_hosts: vec!["api.example.com".into()],
requires_node: false,
requires_native: false,
native_rows: Vec::new(),
client_closures: Vec::new(),
}
}
fn skipped(kind: &str, reason: &str) -> DshOutcome {
DshOutcome {
row: Some("row".into()),
package: Some("pkg".into()),
kind: kind.into(),
outcome: "skipped".into(),
reason: reason.into(),
layer: None,
patch: None,
}
}
fn text_of(summary: &PluginSummary, id: &str) -> Option<String> {
summary
.clauses
.iter()
.find(|clause| clause.id == id)
.map(|clause| clause.text.clone())
}
#[test]
fn summary_plain_plugin_names_source_state_approver_and_adds() {
let inventory = PluginInventory {
skills: 2,
mcp_servers: 1,
stdio_mcp_servers: 1,
hooks: 1,
network_hosts: vec!["api.example.com".into()],
..inventory()
};
let approver = person();
let summary = build_summary(&input(&inventory, &approver));
assert_eq!(
summary.line(),
"notes 1.2.0 from github:acme/notes: on; approved by you; adds 2 skills, 1 local MCP server, 1 hook and network access (api.example.com)"
);
assert_eq!(
summary.ids(),
vec![MSG_HEAD, MSG_STATE_ON, MSG_APPROVER, MSG_ADDS]
);
}
#[test]
fn summary_off_plugin_and_each_approver_kind_read_distinctly() {
let inventory = inventory();
let agent = PluginApprover::Agent(AgentApproval {
posture: AgentPosture::AutoReview,
workspace: "/work".into(),
thread_id: None,
turn_id: None,
tool_call_id: None,
});
let mut request = input(&inventory, &agent);
request.enabled = false;
request.source = None;
let summary = build_summary(&request);
assert_eq!(
summary.line(),
"notes 1.2.0: off; approved by the agent in Auto-Review"
);
let mut request = input(&inventory, &agent);
request.approver = None;
assert!(
build_summary(&request)
.line()
.contains("approver not recorded")
);
}
#[test]
fn summary_dsh_package_counts_conversion_fields_and_panels() {
let mut dsh = conversion();
dsh.requires_node = true;
dsh.client_closures = vec![crate::install::dsh::DshClientClosure {
package: "dsh-whale-widget".into(),
root: String::new(),
entry: "source/client.js".into(),
platform: None,
inject: Vec::new(),
host_provided: Vec::new(),
files: Default::default(),
bytes: 10,
}];
let inventory = inventory();
let approver = person();
let mut request = input(&inventory, &approver);
request.dsh = Some(&dsh);
let summary = build_summary(&request);
assert_eq!(
text_of(&summary, MSG_ADDS).unwrap(),
"adds 1 skill, 2 local MCP servers, 1 remote MCP server, 1 panel and network access (api.example.com)"
);
assert_eq!(
text_of(&summary, MSG_NEEDS_NODE).unwrap(),
"needs Node.js for its local servers"
);
assert!(text_of(&summary, MSG_WONT_RUN).is_none());
}
#[test]
fn summary_refused_and_version_refused_rows_are_counted_with_reasons() {
let mut dsh = conversion();
dsh.outcomes = vec![
skipped("foreign", "no portable representation"),
skipped("mcp", "needs a manual port"),
DshOutcome {
outcome: "converted".into(),
..skipped("mcp", "")
},
];
let inventory = inventory();
let approver = person();
let mut request = input(&inventory, &approver);
request.dsh = Some(&dsh);
assert_eq!(
text_of(&build_summary(&request), MSG_WONT_RUN).unwrap(),
"won't run: 2 rows (each needs a manual port)"
);
dsh.outcomes = vec![skipped(
"patch",
"incompatible-version: this bundle requires DSH `^0.1.0-rc.6`",
)];
let mut request = input(&inventory, &approver);
request.dsh = Some(&dsh);
assert_eq!(
text_of(&build_summary(&request), MSG_WONT_RUN).unwrap(),
"won't run: 1 row (refused by the host version gate)"
);
dsh.outcomes
.push(skipped("foreign", "no portable representation"));
let mut request = input(&inventory, &approver);
request.dsh = Some(&dsh);
assert_eq!(
text_of(&build_summary(&request), MSG_WONT_RUN).unwrap(),
"won't run: 2 rows (1 refused by the host version gate, 1 with no portable form)"
);
}
#[test]
fn summary_native_rows_need_the_extension_host_only_while_it_is_off() {
let mut dsh = conversion();
dsh.requires_native = true;
dsh.native_rows = vec!["worker".into()];
let inventory = inventory();
let approver = person();
let mut request = input(&inventory, &approver);
request.dsh = Some(&dsh);
request.extension_host_on = false;
let off = build_summary(&request);
assert_eq!(
text_of(&off, MSG_NEEDS_EXTENSION_HOST).unwrap(),
"needs the extension host (off)"
);
assert!(
text_of(&off, MSG_ADDS)
.unwrap()
.contains("1 native extension")
);
request.extension_host_on = true;
assert!(text_of(&build_summary(&request), MSG_NEEDS_EXTENSION_HOST).is_none());
}
#[test]
fn summary_update_names_only_what_changed() {
let before = PluginInventory {
skills: 2,
..inventory()
};
let after = PluginInventory {
skills: 2,
hooks: 1,
network_hosts: vec!["api.example.com".into()],
..inventory()
};
let approver = person();
let mut request = input(&after, &approver);
request.previous = Some(PreviousInstall {
name: "notes",
version: "1.1.0",
source: Some("github:acme/notes"),
inventory: &before,
host_services: &[],
});
let summary = build_summary(&request);
assert_eq!(
text_of(&summary, MSG_NOW_ALSO_ADDS).unwrap(),
"now also adds 1 hook and network access (api.example.com)"
);
assert!(text_of(&summary, MSG_ADDS).is_none());
assert!(text_of(&summary, MSG_REPLACES).is_none());
assert!(text_of(&summary, MSG_DOWNGRADED).is_none());
request.previous = Some(PreviousInstall {
name: "notes",
version: "1.1.0",
source: Some("github:acme/notes"),
inventory: &after,
host_services: &[],
});
assert_eq!(
text_of(&build_summary(&request), MSG_ADDS_NOTHING_NEW).unwrap(),
"adds nothing new"
);
}
#[test]
fn summary_source_change_and_downgrade_are_stated() {
let inventory = inventory();
let approver = person();
let mut request = input(&inventory, &approver);
request.previous = Some(PreviousInstall {
name: "notes",
version: "1.4.0",
source: Some("github:old/notes"),
inventory: &inventory,
host_services: &[],
});
request.downgrade_allowed = true;
let summary = build_summary(&request);
assert_eq!(
text_of(&summary, MSG_REPLACES).unwrap(),
"replaces notes from github:old/notes"
);
assert_eq!(
text_of(&summary, MSG_DOWNGRADED).unwrap(),
"downgraded from 1.4.0"
);
request.downgrade_allowed = false;
assert!(text_of(&build_summary(&request), MSG_DOWNGRADED).is_none());
request.previous.as_mut().unwrap().source = None;
assert_eq!(
text_of(&build_summary(&request), MSG_REPLACES).unwrap(),
"replaces notes from an unrecorded source"
);
}
fn review(lines: Vec<HostServiceLine>) -> HostServiceReview {
HostServiceReview::new("user/0/notes", "c", "k", &PluginCohort::Reviewed, lines)
}
fn peer(name: &str) -> ReviewPeer {
ReviewPeer {
id: format!("user/0/{name}"),
name: name.into(),
}
}
#[test]
fn summary_host_services_state_sharing_withheld_and_the_pause() {
let inventory = inventory();
let approver = person();
let review = review(vec![
HostServiceLine {
service: HostService::Workspace,
offered: true,
shared_with: vec![peer("other")],
withheld_by: Vec::new(),
},
HostServiceLine {
service: HostService::Network,
offered: true,
shared_with: Vec::new(),
withheld_by: vec![peer("legacy")],
},
HostServiceLine {
service: HostService::Remote,
offered: false,
shared_with: Vec::new(),
withheld_by: Vec::new(),
},
]);
let mut request = input(&inventory, &approver);
request.host_services = Some(&review);
let summary = build_summary(&request);
assert_eq!(
text_of(&summary, MSG_SERVICES).unwrap(),
"uses host services workspace and network (shared with `other`)"
);
assert_eq!(
text_of(&summary, MSG_SERVICES_WITHHELD).unwrap(),
"off for now: network (`legacy` is turned on without it)"
);
assert_eq!(
text_of(&summary, MSG_SERVICES_UNOFFERED).unwrap(),
"this Codewhale does not offer remote to plugins"
);
assert_eq!(
text_of(&summary, MSG_SERVICES_PAUSE).unwrap(),
"services pause for every plugin in its host until the host is idle"
);
let before = [HostService::Workspace];
let prior = PluginInventory::default();
request.previous = Some(PreviousInstall {
name: "notes",
version: "1.1.0",
source: Some("github:acme/notes"),
inventory: &prior,
host_services: &before,
});
let update = build_summary(&request);
assert_eq!(
text_of(&update, MSG_SERVICES_NOW_ALSO).unwrap(),
"now also uses host services network"
);
assert!(text_of(&update, MSG_SERVICES).is_none());
let empty = self::review(Vec::new());
request.previous = None;
request.host_services = Some(&empty);
assert!(text_of(&build_summary(&request), MSG_SERVICES_PAUSE).is_none());
}
#[test]
fn summary_provider_declarations_say_they_apply_in_a_new_session() {
let inventory = PluginInventory {
providers: 1,
..inventory()
};
let approver = person();
let summary = build_summary(&input(&inventory, &approver));
assert_eq!(
text_of(&summary, MSG_PROVIDER_NEW_SESSION).unwrap(),
"provider changes apply in a new session"
);
assert!(text_of(&summary, MSG_ADDS).unwrap().contains("1 provider"));
}
#[test]
fn summary_long_host_lists_are_shortened() {
let inventory = PluginInventory {
network_hosts: ["a.io", "b.io", "c.io", "d.io", "e.io"]
.map(String::from)
.to_vec(),
..inventory()
};
let approver = person();
assert_eq!(
text_of(&build_summary(&input(&inventory, &approver)), MSG_ADDS).unwrap(),
"adds network access (a.io, b.io, c.io and 2 more)"
);
}
}
-10
View File
@@ -2,16 +2,6 @@
## Unreleased — 0.2.0
- Add data displays: `DataSparkline`, `DataBars` and `DataTrend` (a braille
line chart). The words carry every value; the ink draws in from a caller
progress (`MotionStep::drawn`) and is at its end state under reduced or
still motion. Bars glide from their previous values.
- Add `DataDisplay`, the same displays as JSON an agent can send:
`DataDisplay::from_json` validates it (finite numbers, at most 1024 points
or 24 bars, labels up to 120 characters), `describe` says the same facts as
plain text, and `DATA_DISPLAY_SCHEMA` is its tool schema. Try
`cargo run --example data`.
- Reexport the shared avatar contract from Codewhale Protocol. Rendering and
artwork stay in this library. Protocol 0.10.3 registry proof must precede
this package publication.
-1
View File
@@ -57,7 +57,6 @@ Gallery names are the exact names accepted by the gallery example; the
| Engine `agent_roster.rs::render_agent_roster`, `widgets/workflow_panel.rs::row_receipt_text`, `gate_receipts.rs`; GPUI `usage.rs::TurnRow`, `workbar.rs::Readout`, `working_context.rs::ExecutionReceipt` | `Receipt`, `ReceiptTable`, `ReceiptValue`, `Cost`, formatting helpers | Primitive/composite | `receipt-row`, `receipt-table`, `receipt-table-compact`, `receipt-table-minimal`, `receipt-table-clipped` |
| Engine `diff_render.rs::BoundedDiffRender`, `render_diff_bounded`, `history/file_mutation.rs`; GPUI `review.rs::DiffRowKind`, `workspace/render/review.rs::ReviewRows` | `Diff`, `DiffLine`, `DiffKind`, `DiffGutter`, `DiffHighlight`, `parse_unified` | Primitive | `diff`, `diff-wrapped`, `diff-no-numbers`, `diff-highlighted`, `review-scene` |
| Engine `widgets/workflow_panel.rs::WorkflowPanel`, `WorkflowPanelRow`, `history/checklist.rs`; GPUI `plans.rs`, `workspace/render/workers.rs` | `WorkflowTree`, `TreeNode`, `TreeState`, `CountBar` | Primitive/composite | `workflow-tree`, `workflow-tree-selected`, `workflow-tree-clipped`, `workflow-tree-long`, `count-bars` |
| Kit-native; data from the host or an agent's JSON (CW-8202) | `DataSparkline`, `DataBars`, `DataBarItem`, `DataTrend`, `DataDisplay`, `DataDisplayView`, `DataWords`, `DATA_DISPLAY_SCHEMA`, `MotionStep::drawn` | Primitive | `data-sparkline`, `data-bars`, `data-bars-growing`, `data-trend`, `data-trend-drawing`, `data-from-agent` |
| Engine `approval/view.rs::ApprovalView`, `approval/elevation.rs::ElevationView`, `auto_review.rs`; GPUI `workspace/render/conversation.rs::Workspace::render_attention`, `workspace/render/review.rs::Workspace::render_restore_confirmation` | `ApprovalCard`, `ApprovalSubject`, `ApprovalState`, `ApprovalChoice`, `ApprovalPaint`, `DecisionBand`, `DecisionBandPlan`, `DecisionBandAction`, `DecisionBandSave`, `ReviewVerdict`, `ReviewAggregate` | Composite | `approval-command`, `approval-outside`, `approval-patch`, `approval-elevation`, `approval-clipped`, `approval-spoofed`, `approval-native-band`, `approval-native-band-collapsed`, `review-verdicts`, `review-aggregate` |
| GPUI `workspace/render/conversation.rs::Workspace::render_attention`, `workspace/render/files.rs::Workspace::render_artifact_column` | `AttentionQueue`, `AttentionItem`, `ArtifactShelf`, `Artifact` | Composite | `attention-queue`, `attention-focused`, `attention-narrow`, `attention-empty`, `artifact`, `artifact-shelf`, `artifact-narrow`, `artifact-unknown`, `artifact-empty` |
| Engine `views/mod.rs::ConfigView`, `ConfigRow`, `ConfigView::render_setting_detail`; GPUI `settings.rs::Spec`, `Applies`, `workspace/settings/mod.rs::Settings` | `SettingRow`, `SettingDetail`, `SettingWords` | Composite | `setting-row`, `setting-detail` |
-276
View File
@@ -1,276 +0,0 @@
//! Live data displays: `cargo run --example data`.
//!
//! A sparkline takes a new value every 400 ms, the trend draws in, and the
//! bars glide to new values every three seconds. r replays, m switches
//! between full and reduced motion, q or Escape exits.
//!
//! `--json FILE` shows one agent-sent [`DataDisplay`] instead.
//! `--text` prints the final frame and each display's plain description.
//! `--svg DIR` writes frames at fixed draw-in points.
use std::{
io,
path::PathBuf,
time::{Duration, Instant},
};
use codewhale_ratatui::{
DataBarItem, DataDisplay, DataWords, MotionEasing, MotionMode, MotionPolicy, MotionStep,
MotionTiming, Paint, Role, Theme,
testing::{self, Profile},
};
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend, buffer::Buffer, layout::Rect};
const TICK: Duration = Duration::from_millis(400);
const RESHUFFLE: Duration = Duration::from_secs(3);
const DRAW_IN: MotionTiming =
MotionTiming::space(Duration::from_millis(1_400), MotionEasing::EaseOut);
const GLIDE: MotionTiming = MotionTiming::PANEL;
struct Noise(u64);
impl Noise {
fn next(&mut self) -> f64 {
self.0 = self
.0
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1);
(self.0 >> 33) as f64 / f64::from(1u32 << 31)
}
}
struct Board {
noise: Noise,
requests: Vec<f64>,
latency: Vec<f64>,
bars: DataDisplay,
bars_before: DataDisplay,
draw: MotionStep,
glide: MotionStep,
agent: Option<DataDisplay>,
}
fn crate_bars(noise: &mut Noise) -> DataDisplay {
DataDisplay::Bars {
label: "Tests by crate".into(),
unit: "tests".into(),
items: ["engine", "tui", "runtime", "protocol", "config"]
.into_iter()
.map(|name| DataBarItem::new(name, (40.0 + noise.next() * 400.0).round()))
.collect(),
}
}
impl Board {
fn new(agent: Option<DataDisplay>) -> Self {
let mut noise = Noise(7);
let latency = (0..96)
.map(|i| 80.0 + 45.0 * (f64::from(i) / 9.0).sin() + noise.next() * 30.0)
.collect();
let requests = (0..40).map(|_| 20.0 + noise.next() * 60.0).collect();
let bars = crate_bars(&mut noise);
Self {
noise,
requests,
latency,
bars_before: DataDisplay::Bars {
label: String::new(),
unit: String::new(),
items: Vec::new(),
},
bars,
draw: MotionStep::at_rest(0.0),
glide: MotionStep::at_rest(1.0),
agent,
}
}
fn replay(&mut self, now: Instant, policy: MotionPolicy) {
self.draw = MotionStep::at_rest(0.0);
self.draw.go(1.0, now, DRAW_IN, policy);
}
fn tick(&mut self) {
let last = self.requests.last().copied().unwrap_or(50.0);
let next = (last + (self.noise.next() - 0.5) * 30.0).clamp(5.0, 120.0);
self.requests.push(next.round());
if self.requests.len() > 200 {
self.requests.remove(0);
}
}
fn reshuffle(&mut self, now: Instant, policy: MotionPolicy) {
self.bars_before = std::mem::replace(&mut self.bars, crate_bars(&mut self.noise));
self.glide = MotionStep::at_rest(0.0);
self.glide.go(1.0, now, GLIDE, policy);
}
fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme, draw: f32, glide: f32) {
buf.set_style(area, theme.bg(Role::Background));
let inner = Rect::new(
area.x + 2,
area.y + 1,
area.width.saturating_sub(4),
area.height.saturating_sub(2),
);
if let Some(agent) = &self.agent {
agent.view().drawn(draw).paint(inner, buf, theme);
return;
}
let row =
|y: u16, h: u16| Rect::new(inner.x, inner.y + y, inner.width, h).intersection(inner);
let requests = DataDisplay::Sparkline {
label: "Requests".into(),
unit: "rpm".into(),
values: self.requests.clone(),
};
let latency = DataDisplay::Trend {
label: "Latency".into(),
unit: "ms".into(),
values: self.latency.clone(),
rows: Some(9),
};
requests.view().drawn(draw).paint(row(0, 1), buf, theme);
latency.view().drawn(draw).paint(row(2, 9), buf, theme);
self.bars
.view()
.from(&self.bars_before)
.drawn(glide.min(draw))
.paint(row(12, 6), buf, theme);
}
fn displays(&self) -> Vec<DataDisplay> {
if let Some(agent) = &self.agent {
return vec![agent.clone()];
}
vec![
DataDisplay::Sparkline {
label: "Requests".into(),
unit: "rpm".into(),
values: self.requests.clone(),
},
DataDisplay::Trend {
label: "Latency".into(),
unit: "ms".into(),
values: self.latency.clone(),
rows: Some(9),
},
self.bars.clone(),
]
}
}
fn main() -> io::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let value = |flag: &str| {
args.iter()
.position(|a| a == flag)
.and_then(|i| args.get(i + 1))
.cloned()
};
let agent = match value("--json") {
Some(path) => {
let json = std::fs::read_to_string(&path)?;
match DataDisplay::from_json(&json) {
Ok(shown) => Some(shown),
Err(e) => {
eprintln!("{path}: {e}");
std::process::exit(2);
}
}
}
None => None,
};
let board = Board::new(agent);
if args.iter().any(|a| a == "--text") {
let buf = testing::render(84, 20, |area, buf| {
board.paint(area, buf, &Profile::DarkTrue.theme(), 1.0, 1.0);
});
println!("{}", testing::text(&buf));
for shown in board.displays() {
println!("{}", shown.describe(&DataWords::default()));
}
return Ok(());
}
if let Some(dir) = value("--svg") {
let dir = PathBuf::from(dir);
std::fs::create_dir_all(&dir)?;
let theme = Profile::DarkTrue.theme();
for (i, at) in [0.2_f32, 0.5, 0.8, 1.0].into_iter().enumerate() {
let buf = testing::render(84, 20, |area, buf| board.paint(area, buf, &theme, at, at));
let path = dir.join(format!("data-{i}.svg"));
std::fs::write(&path, testing::svg(&buf, &theme))?;
println!("{}", path.display());
}
return Ok(());
}
run(board)
}
fn run(mut board: Board) -> io::Result<()> {
enable_raw_mode()?;
let mut out = io::stdout();
execute!(out, EnterAlternateScreen)?;
let mut terminal = Terminal::new(CrosstermBackend::new(out))?;
let theme = Theme::detect().tui();
let mut mode = MotionMode::Full;
let mut policy = MotionPolicy::new(mode, &theme);
let start = Instant::now();
board.replay(start, policy);
let (mut next_tick, mut next_shuffle) = (start + TICK, start + RESHUFFLE);
let result = loop {
let now = Instant::now();
if now >= next_tick {
board.tick();
next_tick = now + TICK;
}
if now >= next_shuffle {
board.reshuffle(now, policy);
next_shuffle = now + RESHUFFLE;
}
let (draw, glide) = (
board.draw.drawn(now, policy),
board.glide.drawn(now, policy),
);
terminal.draw(|f| board.paint(f.area(), f.buffer_mut(), &theme, draw, glide))?;
let due = [
board.draw.next_frame_at(now, policy),
board.glide.next_frame_at(now, policy),
]
.into_iter()
.flatten()
.chain([next_tick, next_shuffle])
.min()
.unwrap_or(next_tick);
if event::poll(due.saturating_duration_since(Instant::now()))?
&& let Event::Key(key) = event::read()?
&& key.kind != KeyEventKind::Release
{
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break Ok(()),
KeyCode::Char('r') => board.replay(Instant::now(), policy),
KeyCode::Char('m') => {
mode = if mode == MotionMode::Full {
MotionMode::Reduced
} else {
MotionMode::Full
};
policy = MotionPolicy::new(mode, &theme);
}
_ => {}
}
}
};
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
result
}
File diff suppressed because it is too large. Load diff
-2
View File
@@ -59,12 +59,10 @@ pub use tabs::*;
pub use toggle::*;
// Package Display.
mod chart;
mod diff;
mod progress;
mod receipt;
mod tree;
pub use chart::*;
pub use diff::*;
pub use progress::*;
pub use receipt::*;
-8
View File
@@ -366,14 +366,6 @@ impl MotionStep {
}
}
/// Progress for a draw-in, in `0..=1`: eased where `policy` lets space
/// move, the target where it does not. Feed it to a data display's
/// `drawn`.
#[must_use]
pub fn drawn(&self, now: Instant, policy: MotionPolicy) -> f32 {
self.shown(now, policy, MotionChannel::Space)
}
/// A column between `from` and `to`: slide a marker, nudge a panel.
#[must_use]
pub fn offset(&self, now: Instant, policy: MotionPolicy, from: u16, to: u16) -> u16 {
@@ -1,360 +0,0 @@
---
source: src/components/chart.rs
assertion_line: 1242
expression: "testing::snapshot(12, |area, buf, theme|\n{\n let row = |y: u16, h: u16|\n { Rect::new(area.x, area.y + y, area.width, h).intersection(area) };\n DataSparkline::new(\"Requests\",\n &values).unit(\"rpm\").paint(row(0, 1), buf, theme);\n DataBars::new(\"Tokens by model\",\n &items).unit(\"tokens\").paint(row(2, 5), buf, theme);\n DataTrend::new(\"Latency\",\n &values).unit(\"ms\").rows(5).paint(row(7, 5), buf, theme);\n})"
---
== dark-truecolor · 40x12
Requests ▁▃▅▄▅▆▇███▆▅▄▃▂▁ now 45.6 rpm
Tokens by model
opus ██████████████████ 12.4k tokens
sonnet ███████████▌ 7900 tokens
haiku ████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms
69.7 ms │ ⡠⠒⠑⠒⠒⠢⣀ ⡀⡠⠔⠉⠉⢢⡀
│⢀⠔⠁ ⢇ ⢀⠔⠉ ⠈⠑⢄
│⠁ ⠑⠢⡀ ⡠⠊
17.2 ms │ ⠈⠱⣀⣀⠤⠊⠊
---
r0 c0..8 fg=Foreground bg=Background bold
r0 c8..10 bg=Background
r0 c10..26 fg=Accent bg=Background
r0 c26..28 bg=Background
r0 c28..32 fg=Muted bg=Background
r0 c32..40 fg=Foreground bg=Background
r1 c0..40 bg=Background
r2 c0..15 fg=Foreground bg=Background bold
r2 c15..40 bg=Background
r3 c0..6 fg=Foreground bg=Background
r3 c6..8 bg=Background
r3 c8..26 fg=Accent bg=Background
r3 c26..28 bg=Background
r3 c28..40 fg=Muted bg=Background
r4 c0..6 fg=Foreground bg=Background
r4 c6..8 bg=Background
r4 c8..20 fg=Accent bg=Background
r4 c20..28 bg=Background
r4 c28..39 fg=Muted bg=Background
r4 c39..40 bg=Background
r5 c0..6 fg=Foreground bg=Background
r5 c6..8 bg=Background
r5 c8..13 fg=Accent bg=Background
r5 c13..28 bg=Background
r5 c28..39 fg=Muted bg=Background
r5 c39..40 bg=Background
r6 c0..6 fg=Foreground bg=Background
r6 c6..28 bg=Background
r6 c28..36 fg=Muted bg=Background
r6 c36..40 bg=Background
r7 c0..7 fg=Foreground bg=Background bold
r7 c7..9 bg=Background
r7 c9..13 fg=Muted bg=Background
r7 c13..20 fg=Foreground bg=Background
r7 c20..40 bg=Background
r8 c0..8 fg=Muted bg=Background
r8 c8..9 fg=Dim bg=Background
r8 c9..11 bg=Background
r8 c11..18 fg=Accent bg=Background
r8 c18..31 bg=Background
r8 c31..38 fg=Accent bg=Background
r8 c38..40 bg=Background
r9 c0..8 fg=Muted bg=Background
r9 c8..9 fg=Dim bg=Background
r9 c9..12 fg=Accent bg=Background
r9 c12..18 bg=Background
r9 c18..19 fg=Accent bg=Background
r9 c19..29 bg=Background
r9 c29..32 fg=Accent bg=Background
r9 c32..37 bg=Background
r9 c37..40 fg=Accent bg=Background
r10 c0..8 fg=Muted bg=Background
r10 c8..9 fg=Dim bg=Background
r10 c9..10 fg=Accent bg=Background
r10 c10..19 bg=Background
r10 c19..22 fg=Accent bg=Background
r10 c22..28 bg=Background
r10 c28..30 fg=Accent bg=Background
r10 c30..40 bg=Background
r11 c0..8 fg=Muted bg=Background
r11 c8..9 fg=Dim bg=Background
r11 c9..21 bg=Background
r11 c21..28 fg=Accent bg=Background
r11 c28..40 bg=Background
== dark-truecolor · 80x12
Requests ▂▁▁▁▁▁▂▂▂▃▃▄▅▆▆▆▇▇████▇▇▆▆▅▅ now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ██████████████████████████████████████████████████████████ 12.4k tokens
sonnet █████████████████████████████████████ 7900 tokens
haiku ██████████████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms │ ⣀⠤⠒⠊⠑⠒⠒⠒⠒⠒⠒⠢⠤⢄⣀ ⣀ ⣀⡠⠤⠒⠒⠉⠉⠉⠉⠢⢄⣀⣀
│ ⣀⠤⠒⠉ ⠑⠤⣀ ⢀⡠⠒⠉ ⠉ ⠉⠑⠒⠤⣀
│⠉ ⠉⠑⠒⠤⢄⣀ ⡠⠔⠊⠁
17.2 ms │ ⠉⠑⢄⣀⣀⣀⣀⣀⠤⠤⠒⠊⠒⠊⠉
---
r0 c0..8 fg=Foreground bg=Background bold
r0 c8..10 bg=Background
r0 c10..38 fg=Accent bg=Background
r0 c38..40 bg=Background
r0 c40..44 fg=Muted bg=Background
r0 c44..52 fg=Foreground bg=Background
r0 c52..54 bg=Background
r0 c54..58 fg=Muted bg=Background
r0 c58..66 fg=Foreground bg=Background
r0 c66..68 bg=Background
r0 c68..72 fg=Muted bg=Background
r0 c72..80 fg=Foreground bg=Background
r1 c0..80 bg=Background
r2 c0..15 fg=Foreground bg=Background bold
r2 c15..80 bg=Background
r3 c0..6 fg=Foreground bg=Background
r3 c6..8 bg=Background
r3 c8..66 fg=Accent bg=Background
r3 c66..68 bg=Background
r3 c68..80 fg=Muted bg=Background
r4 c0..6 fg=Foreground bg=Background
r4 c6..8 bg=Background
r4 c8..45 fg=Accent bg=Background
r4 c45..68 bg=Background
r4 c68..79 fg=Muted bg=Background
r4 c79..80 bg=Background
r5 c0..6 fg=Foreground bg=Background
r5 c6..8 bg=Background
r5 c8..23 fg=Accent bg=Background
r5 c23..68 bg=Background
r5 c68..79 fg=Muted bg=Background
r5 c79..80 bg=Background
r6 c0..6 fg=Foreground bg=Background
r6 c6..68 bg=Background
r6 c68..76 fg=Muted bg=Background
r6 c76..80 bg=Background
r7 c0..7 fg=Foreground bg=Background bold
r7 c7..9 bg=Background
r7 c9..13 fg=Muted bg=Background
r7 c13..20 fg=Foreground bg=Background
r7 c20..22 bg=Background
r7 c22..26 fg=Muted bg=Background
r7 c26..33 fg=Foreground bg=Background
r7 c33..35 bg=Background
r7 c35..39 fg=Muted bg=Background
r7 c39..46 fg=Foreground bg=Background
r7 c46..80 bg=Background
r8 c0..8 fg=Muted bg=Background
r8 c8..9 fg=Dim bg=Background
r8 c9..14 bg=Background
r8 c14..29 fg=Accent bg=Background
r8 c29..60 bg=Background
r8 c60..61 fg=Accent bg=Background
r8 c61..62 bg=Background
r8 c62..75 fg=Accent bg=Background
r8 c75..80 bg=Background
r9 c0..8 fg=Muted bg=Background
r9 c8..9 fg=Dim bg=Background
r9 c9..10 bg=Background
r9 c10..14 fg=Accent bg=Background
r9 c14..29 bg=Background
r9 c29..32 fg=Accent bg=Background
r9 c32..56 bg=Background
r9 c56..60 fg=Accent bg=Background
r9 c60..61 bg=Background
r9 c61..62 fg=Accent bg=Background
r9 c62..75 bg=Background
r9 c75..80 fg=Accent bg=Background
r10 c0..8 fg=Muted bg=Background
r10 c8..9 fg=Dim bg=Background
r10 c9..10 fg=Accent bg=Background
r10 c10..32 bg=Background
r10 c32..38 fg=Accent bg=Background
r10 c38..53 bg=Background
r10 c53..57 fg=Accent bg=Background
r10 c57..80 bg=Background
r11 c0..8 fg=Muted bg=Background
r11 c8..9 fg=Dim bg=Background
r11 c9..38 bg=Background
r11 c38..53 fg=Accent bg=Background
r11 c53..80 bg=Background
== dark-truecolor · 120x12
Requests ▄▅▆▆▇██▇██▇▇▇▇▅▅▄▄▃▃▂▁▁▁▁▁▂▂▂▃▃▄▅▆▆▆▇▇████▇▇▆▆▅▅ now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ██████████████████████████████████████████████████████████████████████████████████████████████████ 12.4k tokens
sonnet ██████████████████████████████████████████████████████████████▍ 7900 tokens
haiku ████████████████████████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms │ ⢀⣀⠤⠤⠒⠒⠊⠉⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠢⠤⠤⠤⣀⣀ ⣀⡀ ⣀⣀⠤⠤⠒⠒⠒⠊⠉⠉⠉⠉⠉⠉⠒⠤⢄⣀⣀⣀
│ ⢀⣀⠤⠔⠒⠉⠁ ⠉⠢⠤⣀⣀ ⣀⡠⠤⠒⠊⠉ ⠈⠉ ⠉⠉⠑⠒⠒⠤⠤⣀
│⠉⠁ ⠉⠉⠒⠒⠢⠤⢄⣀⣀ ⢀⣀⠤⠔⠊⠉
17.2 ms │ ⠉⠉⠑⠢⢄⣀⣀⣀⣀⣀⣀⣀⡠⠤⠤⠤⠒⠒⠊⠑⠒⠒⠉⠁
---
r0 c0..8 fg=Foreground bg=Background bold
r0 c8..10 bg=Background
r0 c10..58 fg=Accent bg=Background
r0 c58..60 bg=Background
r0 c60..64 fg=Muted bg=Background
r0 c64..72 fg=Foreground bg=Background
r0 c72..74 bg=Background
r0 c74..78 fg=Muted bg=Background
r0 c78..86 fg=Foreground bg=Background
r0 c86..88 bg=Background
r0 c88..92 fg=Muted bg=Background
r0 c92..100 fg=Foreground bg=Background
r0 c100..120 bg=Background
r1 c0..120 bg=Background
r2 c0..15 fg=Foreground bg=Background bold
r2 c15..120 bg=Background
r3 c0..6 fg=Foreground bg=Background
r3 c6..8 bg=Background
r3 c8..106 fg=Accent bg=Background
r3 c106..108 bg=Background
r3 c108..120 fg=Muted bg=Background
r4 c0..6 fg=Foreground bg=Background
r4 c6..8 bg=Background
r4 c8..71 fg=Accent bg=Background
r4 c71..108 bg=Background
r4 c108..119 fg=Muted bg=Background
r4 c119..120 bg=Background
r5 c0..6 fg=Foreground bg=Background
r5 c6..8 bg=Background
r5 c8..33 fg=Accent bg=Background
r5 c33..108 bg=Background
r5 c108..119 fg=Muted bg=Background
r5 c119..120 bg=Background
r6 c0..6 fg=Foreground bg=Background
r6 c6..108 bg=Background
r6 c108..116 fg=Muted bg=Background
r6 c116..120 bg=Background
r7 c0..7 fg=Foreground bg=Background bold
r7 c7..9 bg=Background
r7 c9..13 fg=Muted bg=Background
r7 c13..20 fg=Foreground bg=Background
r7 c20..22 bg=Background
r7 c22..26 fg=Muted bg=Background
r7 c26..33 fg=Foreground bg=Background
r7 c33..35 bg=Background
r7 c35..39 fg=Muted bg=Background
r7 c39..46 fg=Foreground bg=Background
r7 c46..120 bg=Background
r8 c0..8 fg=Muted bg=Background
r8 c8..9 fg=Dim bg=Background
r8 c9..16 bg=Background
r8 c16..40 fg=Accent bg=Background
r8 c40..89 bg=Background
r8 c89..91 fg=Accent bg=Background
r8 c91..92 bg=Background
r8 c92..112 fg=Accent bg=Background
r8 c112..120 bg=Background
r9 c0..8 fg=Muted bg=Background
r9 c8..9 fg=Dim bg=Background
r9 c9..10 bg=Background
r9 c10..17 fg=Accent bg=Background
r9 c17..40 bg=Background
r9 c40..45 fg=Accent bg=Background
r9 c45..83 bg=Background
r9 c83..89 fg=Accent bg=Background
r9 c89..90 bg=Background
r9 c90..92 fg=Accent bg=Background
r9 c92..112 bg=Background
r9 c112..120 fg=Accent bg=Background
r10 c0..8 fg=Muted bg=Background
r10 c8..9 fg=Dim bg=Background
r10 c9..11 fg=Accent bg=Background
r10 c11..45 bg=Background
r10 c45..54 fg=Accent bg=Background
r10 c54..77 bg=Background
r10 c77..83 fg=Accent bg=Background
r10 c83..120 bg=Background
r11 c0..8 fg=Muted bg=Background
r11 c8..9 fg=Dim bg=Background
r11 c9..54 bg=Background
r11 c54..78 fg=Accent bg=Background
r11 c78..120 bg=Background
== no-color · 40x12
Requests ▁▃▅▄▅▆▇███▆▅▄▃▂▁ now 45.6 rpm
Tokens by model
opus ██████████████████ 12.4k tokens
sonnet ███████████▌ 7900 tokens
haiku ████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms
69.7 ms │ ⡠⠒⠑⠒⠒⠢⣀ ⡀⡠⠔⠉⠉⢢⡀
│⢀⠔⠁ ⢇ ⢀⠔⠉ ⠈⠑⢄
│⠁ ⠑⠢⡀ ⡠⠊
17.2 ms │ ⠈⠱⣀⣀⠤⠊⠊
== no-color · 80x12
Requests ▂▁▁▁▁▁▂▂▂▃▃▄▅▆▆▆▇▇████▇▇▆▆▅▅ now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ██████████████████████████████████████████████████████████ 12.4k tokens
sonnet █████████████████████████████████████ 7900 tokens
haiku ██████████████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms │ ⣀⠤⠒⠊⠑⠒⠒⠒⠒⠒⠒⠢⠤⢄⣀ ⣀ ⣀⡠⠤⠒⠒⠉⠉⠉⠉⠢⢄⣀⣀
│ ⣀⠤⠒⠉ ⠑⠤⣀ ⢀⡠⠒⠉ ⠉ ⠉⠑⠒⠤⣀
│⠉ ⠉⠑⠒⠤⢄⣀ ⡠⠔⠊⠁
17.2 ms │ ⠉⠑⢄⣀⣀⣀⣀⣀⠤⠤⠒⠊⠒⠊⠉
== no-color · 120x12
Requests ▄▅▆▆▇██▇██▇▇▇▇▅▅▄▄▃▃▂▁▁▁▁▁▂▂▂▃▃▄▅▆▆▆▇▇████▇▇▆▆▅▅ now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ██████████████████████████████████████████████████████████████████████████████████████████████████ 12.4k tokens
sonnet ██████████████████████████████████████████████████████████████▍ 7900 tokens
haiku ████████████████████████▌ 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms │ ⢀⣀⠤⠤⠒⠒⠊⠉⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠢⠤⠤⠤⣀⣀ ⣀⡀ ⣀⣀⠤⠤⠒⠒⠒⠊⠉⠉⠉⠉⠉⠉⠒⠤⢄⣀⣀⣀
│ ⢀⣀⠤⠔⠒⠉⠁ ⠉⠢⠤⣀⣀ ⣀⡠⠤⠒⠊⠉ ⠈⠉ ⠉⠉⠑⠒⠒⠤⠤⣀
│⠉⠁ ⠉⠉⠒⠒⠢⠤⢄⣀⣀ ⢀⣀⠤⠔⠊⠉
17.2 ms │ ⠉⠉⠑⠢⢄⣀⣀⣀⣀⣀⣀⣀⡠⠤⠤⠤⠒⠒⠊⠑⠒⠒⠉⠁
== ascii · 40x12
Requests _.---=####=--.__ now 45.6 rpm
Tokens by model
opus ################## 12.4k tokens
sonnet ############ 7900 tokens
haiku ##### 3100 tokens
cache 0 tokens
Latency now 45.6 ms
69.7 ms | ....... .....:.
|... : ... ...
|. ... ..
17.2 ms | .:.....
== ascii · 80x12
Requests _________..--===########==-- now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ########################################################## 12.4k tokens
sonnet ##################################### 7900 tokens
haiku ############### 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms | ............... . .............
| .... ... .... . .....
|. ...... ....
17.2 ms | ...............
== ascii · 120x12
Requests --==##########----.._________..--===########==-- now 45.6 rpm min 17.2 rpm max 69.7 rpm
Tokens by model
opus ################################################################################################## 12.4k tokens
sonnet ############################################################### 7900 tokens
haiku ######################### 3100 tokens
cache 0 tokens
Latency now 45.6 ms min 17.2 ms max 69.7 ms
69.7 ms | ........................ .. ....................
| ....... ..... ...... .. ........
|.. ......... ......
17.2 ms | ........................
-118
View File
@@ -1,118 +0,0 @@
//! Gallery: data displays fully drawn, mid draw-in, empty and from agent JSON.
use ratatui::{buffer::Buffer, layout::Rect};
use super::Entry;
use crate::{DataBarItem, DataBars, DataDisplay, DataSparkline, DataTrend, Paint, Theme};
fn series() -> Vec<f64> {
(0..64)
.map(|i| 120.0 + 60.0 * (f64::from(i) / 6.0).sin() + f64::from((i * 7) % 11) * 3.0)
.collect()
}
fn items() -> Vec<DataBarItem> {
vec![
DataBarItem::new("engine", 412.0),
DataBarItem::new("tui", 268.0),
DataBarItem::new("runtime", 131.0),
DataBarItem::new("protocol", 44.0),
]
}
fn row(area: Rect, y: u16, h: u16) -> Rect {
Rect::new(area.x, area.y.saturating_add(y), area.width, h).intersection(area)
}
fn sparklines(area: Rect, buf: &mut Buffer, theme: &Theme) {
let values = series();
DataSparkline::new("Requests", &values)
.unit("rpm")
.paint(row(area, 0, 1), buf, theme);
DataSparkline::new("Requests", &values)
.unit("rpm")
.drawn(0.45)
.paint(row(area, 1, 1), buf, theme);
DataSparkline::new("Requests", &[]).paint(row(area, 2, 1), buf, theme);
}
fn bars(area: Rect, buf: &mut Buffer, theme: &Theme) {
let items = items();
DataBars::new("Tests by crate", &items).paint(row(area, 0, 5), buf, theme);
}
fn bars_growing(area: Rect, buf: &mut Buffer, theme: &Theme) {
let items = items();
DataBars::new("Tests by crate", &items)
.previous(&[300.0, 300.0, 300.0, 300.0])
.drawn(0.5)
.paint(row(area, 0, 5), buf, theme);
}
fn trend(area: Rect, buf: &mut Buffer, theme: &Theme) {
let values = series();
DataTrend::new("Latency", &values)
.unit("ms")
.rows(7)
.paint(area, buf, theme);
}
fn trend_drawing(area: Rect, buf: &mut Buffer, theme: &Theme) {
let values = series();
DataTrend::new("Latency", &values)
.unit("ms")
.rows(7)
.drawn(0.6)
.paint(area, buf, theme);
}
fn from_agent(area: Rect, buf: &mut Buffer, theme: &Theme) {
let shown = DataDisplay::from_json(
r#"{"kind":"bars","label":"Tokens this turn","unit":"tokens",
"items":[{"label":"read","value":18200},{"label":"wrote","value":2400},
{"label":"cached","value":61000}]}"#,
)
.expect("gallery fixture is a valid data display");
shown.view().paint(area, buf, theme);
}
pub(crate) fn entries() -> Vec<Entry> {
vec![
Entry {
name: "data-sparkline",
width: 80,
height: 3,
draw: sparklines,
},
Entry {
name: "data-bars",
width: 60,
height: 5,
draw: bars,
},
Entry {
name: "data-bars-growing",
width: 60,
height: 5,
draw: bars_growing,
},
Entry {
name: "data-trend",
width: 72,
height: 7,
draw: trend,
},
Entry {
name: "data-trend-drawing",
width: 72,
height: 7,
draw: trend_drawing,
},
Entry {
name: "data-from-agent",
width: 60,
height: 4,
draw: from_agent,
},
]
}
-2
View File
@@ -31,7 +31,6 @@ mod approval;
mod artifacts;
mod atmosphere;
mod attention;
mod chart;
mod chrome;
mod display;
mod habitat;
@@ -105,7 +104,6 @@ pub fn entries() -> Vec<Entry> {
lists::entries(),
chrome::entries(),
display::entries(),
chart::entries(),
approval::entries(),
motion::entries(),
workspace::entries(),