diff --git a/Cargo.toml b/Cargo.toml index 4bcc31f..1facf25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,7 +89,7 @@ name = "bee" path = "src/lib.rs" [features] -default = [] +default = ["tui"] # Real kernel enforcement. Pulls bee-userspace's eBPF-LSM path (nightly bpf toolchain + BPF-LSM # kernel to build/attach). WITHOUT this feature the harness runs tools as hardened, credential- # stripped host processes with NO scope — enough for offline (MockModel) tests and live-provider @@ -179,10 +179,12 @@ rmcp = { version = "2.2", default-features = false, features = ["client", "trans # still pins `ratatui ^0.29`, which cannot unify with the 0.30 the TUI is built on, so cargo would # resolve a *second* ratatui whose `Text`/`Buffer` are different types — widgets from one cannot # render into the other's buffer. `tui-markdown` 0.3.8 moved to `ratatui-core ^0.1`, which is exactly -# what ratatui 0.30 is built on, so there is one ratatui in the tree. `default-features = false` -# drops `highlight-code` (and its `syntect` + `ansi-to-tui` deps): bee colors code blocks from the -# active theme's roles (005-themes), not from a second, theme-blind highlighter. -tui-markdown = { version = "0.3.8", default-features = false, optional = true } +# what ratatui 0.30 is built on, so there is one ratatui in the tree. `highlight-code` (syntect) +# colors recognized fenced blocks; 005's objection — a second, theme-blind highlighter — is answered +# in `tui::markdown`, which picks the bundled code theme nearest the active bee theme and strips the +# highlighter's colors under `NO_COLOR`. `default-features = false` + the explicit feature keeps the +# list deliberate rather than whatever the crate's default grows into. +tui-markdown = { version = "0.3.8", default-features = false, features = ["highlight-code"], optional = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/src/app/repl.rs b/src/app/repl.rs index ac6c597..5592aff 100644 --- a/src/app/repl.rs +++ b/src/app/repl.rs @@ -54,7 +54,8 @@ pub struct ReplArgs { pub no_bee: bool, /// Color theme (built-in name or a custom one from config). Overrides `BEE_THEME` and the /// config file. Built-ins: honeycomb (default), catppuccin-{mocha,latte,frappe,macchiato}, - /// dracula, nord. + /// dracula, nord, gruvbox, tokyo-night, rose-pine — or `auto` to pick a flavor from the + /// terminal's detected light/dark background. #[arg(long)] pub theme: Option, /// Standalone MCP config TOML (top-level `[mcp]` + `[[mcp.servers]]`). Requires building with diff --git a/src/app/run.rs b/src/app/run.rs index 0669d13..edbad8a 100644 --- a/src/app/run.rs +++ b/src/app/run.rs @@ -76,7 +76,7 @@ pub struct RunArgs { #[arg(long)] pub quiet: bool, /// Color theme (built-in name or a custom one from config). Overrides `BEE_THEME` and the - /// config file. + /// config file. `auto` picks a flavor from the terminal's detected light/dark background. #[arg(long)] pub theme: Option, /// How much screen the agent may claim: `none`, `panels`, `panels-wide`, or `takeover`. diff --git a/src/render_api.rs b/src/render_api.rs index f5b0d06..64bfbeb 100644 --- a/src/render_api.rs +++ b/src/render_api.rs @@ -11,8 +11,8 @@ use std::sync::{Arc, Mutex}; use rhai::{Array, Dynamic, Engine, EvalAltResult}; use crate::render_spec::{ - AnimationSpec, Bar, Direction, Dot, DotState, EffectSpec, GridCell, PanelOp, Point, RenderSpec, - Renderable, Row, Series, SpriteSpec, + AnimationSpec, Bar, Direction, Dot, DotState, EffectSpec, GridCell, HeatRow, PanelOp, Point, + RenderSpec, Renderable, Row, Series, SpriteSpec, }; use crate::visual_gate; @@ -229,6 +229,8 @@ fn validate_panel_id(id: &str) -> Result<(), Box> { enum ChartKind { Bar, Line, + Scatter, + Area, Spark, } @@ -282,6 +284,29 @@ impl ChartBuilder { }) .collect(), }, + ChartKind::Scatter => RenderSpec::ScatterPlot { + title: self.title.clone(), + series: self + .series + .iter() + .map(|(label, pts)| Series { + label: label.clone(), + points: pts.lock().expect("series").clone(), + }) + .collect(), + }, + ChartKind::Area => RenderSpec::AreaChart { + title: self.title.clone(), + series: self + .series + .iter() + .map(|(label, pts)| Series { + label: label.clone(), + points: pts.lock().expect("series").clone(), + }) + .collect(), + color: self.color.clone(), + }, ChartKind::Spark => RenderSpec::Sparkline { title: self.title.clone(), data: self.spark.clone(), @@ -296,6 +321,25 @@ pub struct SeriesHandle { points: Arc>>, } +/// Builder behind `heatmap`. +#[derive(Clone)] +pub struct HeatmapBuilder { + title: String, + rows: Vec, + color: Option, + effect: Option, +} + +impl HeatmapBuilder { + fn to_spec(&self) -> RenderSpec { + RenderSpec::Heatmap { + title: self.title.clone(), + rows: self.rows.clone(), + color: self.color.clone(), + } + } +} + /// Builder behind `table`. #[derive(Clone)] pub struct TableBuilder { @@ -359,6 +403,27 @@ impl DotGridBuilder { } } +/// Builder behind `log_tail`. +#[derive(Clone)] +pub struct LogTailBuilder { + title: String, + lines: Vec, + max_rows: Option, + /// The transition the agent attached with `widget.effect(e)` (009 FR-022). + /// `None` means the default transition for the target, never "no animation". + effect: Option, +} + +impl LogTailBuilder { + fn to_spec(&self) -> RenderSpec { + RenderSpec::LogTail { + title: self.title.clone(), + lines: self.lines.clone(), + max_rows: self.max_rows, + } + } +} + /// Builder behind `text`. #[derive(Clone)] pub struct TextBuilder { @@ -596,6 +661,13 @@ fn dynamic_to_renderable(d: Dynamic) -> Result> { effect: b.effect, }); } + if d.is::() { + let b = d.cast::(); + return Ok(Renderable { + spec: b.to_spec(), + effect: b.effect, + }); + } if d.is::() { let b = d.cast::(); return Ok(Renderable { @@ -617,6 +689,13 @@ fn dynamic_to_renderable(d: Dynamic) -> Result> { effect: b.effect, }); } + if d.is::() { + let b = d.cast::(); + return Ok(Renderable { + spec: b.to_spec(), + effect: b.effect, + }); + } if d.is::() { let b = d.cast::(); return Ok(Renderable { @@ -686,6 +765,7 @@ pub fn register(engine: &mut Engine, ctx: RenderContext) { engine.register_type_with_name::("Table"); engine.register_type_with_name::("Gauge"); engine.register_type_with_name::("DotGrid"); + engine.register_type_with_name::("LogTail"); engine.register_type_with_name::("Text"); engine.register_type_with_name::("Layout"); engine.register_type_with_name::("Widget"); @@ -698,6 +778,12 @@ pub fn register(engine: &mut Engine, ctx: RenderContext) { engine.register_fn("line_chart", |title: String| { ChartBuilder::new(ChartKind::Line, title) }); + engine.register_fn("scatter", |title: String| { + ChartBuilder::new(ChartKind::Scatter, title) + }); + engine.register_fn("area_chart", |title: String| { + ChartBuilder::new(ChartKind::Area, title) + }); engine.register_fn("sparkline", |title: String, data: Array| { let mut c = ChartBuilder::new(ChartKind::Spark, title); c.spark = array_to_u64(data); @@ -723,6 +809,42 @@ pub fn register(engine: &mut Engine, ctx: RenderContext) { engine.register_fn("point", |s: &mut SeriesHandle, x: f64, y: f64| { s.points.lock().expect("series").push(Point { x, y }); }); + engine.register_fn("point", |s: &mut SeriesHandle, x: i64, y: i64| { + s.points.lock().expect("series").push(Point { + x: x as f64, + y: y as f64, + }); + }); + + // --- Heatmaps --- + engine.register_type_with_name::("Heatmap"); + engine.register_fn("heatmap", |title: String| HeatmapBuilder { + title, + rows: Vec::new(), + color: None, + effect: None, + }); + engine.register_fn( + "row", + |h: &mut HeatmapBuilder, label: String, values: Array| { + h.rows.push(HeatRow { + label, + values: values + .into_iter() + .map(|v| { + if let Ok(f) = v.as_float() { + f + } else { + v.as_int().unwrap_or(0) as f64 + } + }) + .collect(), + }); + }, + ); + engine.register_fn("color", |h: &mut HeatmapBuilder, name: String| { + h.color = Some(name); + }); // --- Tables --- engine.register_fn("table", |title: String| TableBuilder { @@ -762,6 +884,29 @@ pub fn register(engine: &mut Engine, ctx: RenderContext) { engine.register_fn("color", |g: &mut GaugeBuilder, name: String| { g.color = Some(name) }); + engine.register_fn("log_tail", |title: String| LogTailBuilder { + title, + lines: Vec::new(), + max_rows: None, + effect: None, + }); + engine.register_fn("line", |l: &mut LogTailBuilder, text: String| { + l.lines + .push(crate::render_spec::LogLine { text, level: None }); + }); + engine.register_fn( + "line", + |l: &mut LogTailBuilder, text: String, level: String| { + l.lines.push(crate::render_spec::LogLine { + text, + level: Some(level), + }); + }, + ); + engine.register_fn("max_rows", |l: &mut LogTailBuilder, n: i64| { + l.max_rows = Some(n.clamp(1, 40) as u16); + }); + engine.register_fn("dots", |title: String| DotGridBuilder { title, dots: Vec::new(), @@ -797,6 +942,9 @@ pub fn register(engine: &mut Engine, ctx: RenderContext) { engine.register_fn("style", |t: &mut TextBuilder, name: String| { t.style = Some(name) }); + engine.register_fn("color", |t: &mut TextBuilder, name: String| { + t.style = Some(name) + }); engine.register_fn("bold", |t: &mut TextBuilder| t.bold = true); engine.register_fn("dim", |t: &mut TextBuilder| t.dim = true); // Markdown the agent writes as source and bee renders through the active theme (010). One verb, @@ -1079,8 +1227,10 @@ pub fn register(engine: &mut Engine, ctx: RenderContext) { } attach_effect!( ChartBuilder, + HeatmapBuilder, TableBuilder, GaugeBuilder, + LogTailBuilder, DotGridBuilder, TextBuilder, LayoutBuilder, @@ -1184,6 +1334,36 @@ mod tests { )); } + #[test] + fn log_tail_builds_lines_with_and_without_levels() { + let out = run(concat!( + r#"let l = log_tail("denials");"#, + r#" l.line("policy loaded");"#, + r#" l.line("open /etc/shadow blocked", "error");"#, + r#" l.max_rows(4);"#, + r#" render_to("audit", l);"#, + )) + .unwrap(); + let [PanelOp::Upsert { id, spec, .. }] = &out.panel_ops[..] else { + panic!("expected one upsert, got {:?}", out.panel_ops); + }; + assert_eq!(id, "audit"); + match spec { + RenderSpec::LogTail { + title, + lines, + max_rows, + } => { + assert_eq!(title, "denials"); + assert_eq!(max_rows, &Some(4)); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].level, None); + assert_eq!(lines[1].level.as_deref(), Some("error")); + } + other => panic!("expected a log tail, got {other:?}"), + } + } + #[test] fn render_commits_inline_and_no_panel_ops() { let out = run(r#"render(text("hi"));"#).unwrap(); diff --git a/src/render_spec.rs b/src/render_spec.rs index 64a74c5..9d37d03 100644 --- a/src/render_spec.rs +++ b/src/render_spec.rs @@ -34,6 +34,13 @@ pub struct Point { pub y: f64, } +/// One labelled row of a [`RenderSpec::Heatmap`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct HeatRow { + pub label: String, + pub values: Vec, +} + /// One table row: its cells plus an optional row color (palette or basic-ANSI name). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Row { @@ -42,6 +49,15 @@ pub struct Row { pub color: Option, } +/// One line of a [`RenderSpec::LogTail`]: its text plus an optional severity level +/// (`error`/`warn`/`info`/`debug`/`trace`) that drives the gutter marker and color. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LogLine { + pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub level: Option, +} + /// One labelled dot of a [`RenderSpec::DotGrid`]. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Dot { @@ -260,10 +276,36 @@ pub enum RenderSpec { title: String, series: Vec, }, + ScatterPlot { + title: String, + series: Vec, + }, + AreaChart { + title: String, + series: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + color: Option, + }, + Heatmap { + title: String, + rows: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + color: Option, + }, Sparkline { title: String, data: Vec, }, + /// A bottom-anchored stream tail: the newest lines that fit, oldest scrolling off the top — + /// built for feeds like sandbox denials landing while an episode runs. Levels are marked with a + /// letter as well as a color, so the stream still reads in monochrome. + LogTail { + title: String, + lines: Vec, + /// Content rows shown (default 8): the tail keeps the newest `max_rows` lines. + #[serde(default, skip_serializing_if = "Option::is_none")] + max_rows: Option, + }, Table { title: String, headers: Vec, @@ -335,8 +377,12 @@ impl RenderSpec { pub fn element_count(&self) -> usize { match self { RenderSpec::BarChart { bars, .. } => bars.len(), - RenderSpec::LineChart { series, .. } => series.iter().map(|s| s.points.len()).sum(), + RenderSpec::LineChart { series, .. } + | RenderSpec::ScatterPlot { series, .. } + | RenderSpec::AreaChart { series, .. } => series.iter().map(|s| s.points.len()).sum(), + RenderSpec::Heatmap { rows, .. } => rows.iter().map(|r| r.values.len()).sum(), RenderSpec::Sparkline { data, .. } => data.len(), + RenderSpec::LogTail { lines, .. } => lines.len(), RenderSpec::Table { rows, .. } => rows.len(), RenderSpec::DotGrid { dots, .. } => dots.len(), RenderSpec::Layout { children, .. } => children.iter().map(|c| c.element_count()).sum(), @@ -387,16 +433,35 @@ impl RenderSpec { } s } - RenderSpec::LineChart { title, series } => { + RenderSpec::LineChart { title, series } + | RenderSpec::ScatterPlot { title, series, .. } + | RenderSpec::AreaChart { title, series, .. } => { let mut s = format!("{title}\n"); for ser in series { s.push_str(&format!(" {} ({} points)\n", ser.label, ser.points.len())); } s } + RenderSpec::Heatmap { title, rows, .. } => { + let mut s = format!("{title}\n"); + for r in rows { + s.push_str(&format!(" {}: {:?}\n", r.label, r.values)); + } + s + } RenderSpec::Sparkline { title, data } => { format!("{title}\n {data:?}\n") } + RenderSpec::LogTail { title, lines, .. } => { + let mut s = format!("{title}\n"); + for l in lines { + match &l.level { + Some(lv) => s.push_str(&format!(" [{lv}] {}\n", l.text)), + None => s.push_str(&format!(" {}\n", l.text)), + } + } + s + } RenderSpec::Table { title, headers, @@ -468,9 +533,22 @@ impl RenderSpec { RenderSpec::LineChart { title, series } => { format!("a line chart {title:?} with {} series", series.len()) } + RenderSpec::ScatterPlot { title, series, .. } => { + format!("a scatter plot {title:?} with {} series", series.len()) + } + RenderSpec::AreaChart { title, series, .. } => { + format!("an area chart {title:?} with {} series", series.len()) + } + RenderSpec::Heatmap { title, rows, .. } => { + let cols = rows.first().map_or(0, |r| r.values.len()); + format!("a heatmap {title:?} ({}×{})", rows.len(), cols) + } RenderSpec::Sparkline { title, data } => { format!("a sparkline {title:?} with {} points", data.len()) } + RenderSpec::LogTail { title, lines, .. } => { + format!("a log tail {title:?} with {} lines", lines.len()) + } RenderSpec::Table { title, headers, diff --git a/src/repl.rs b/src/repl.rs index 33f3aca..3314c9b 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -35,7 +35,7 @@ use time::OffsetDateTime; use crate::provider::{ Conversation, Message, Model, ModelError, StreamEvent, ToolSchema, Turn, Usage, }; -use crate::render_spec::RenderSpec; +use crate::render_spec::{EffectSpec, RenderSpec}; use crate::sandbox::Sandbox; use crate::tools::{ToolRegistry, ToolResult}; use crate::transcript::{EpisodeStatus, EpisodeTranscript, RecordedCall, Timing, TranscriptTurn}; @@ -148,7 +148,7 @@ pub trait ReplOutput: Send + Sync { /// Render a visualization produced by the `render` tool (003-visual-render, FR-024). The default /// emits a plain-text ASCII fallback through [`ReplOutput::info`] — a text table, never a blank /// (US6 AS-5). `TerminalOutput` overrides this to draw the widget as ANSI art. - fn render_widget(&self, spec: &RenderSpec) { + fn render_widget(&self, spec: &RenderSpec, _effect: Option<&EffectSpec>) { for line in spec.to_ascii().lines() { self.info(line); } @@ -156,15 +156,15 @@ pub trait ReplOutput: Send + Sync { /// A full-screen takeover (009-tachyonfx-effects, FR-013a). Defaults to drawing inline: the /// inline REPL scrolls and has no surface to take over, so a takeover there is just a widget in /// the flow (009 spec, Edge Cases). The full-screen TUI's `SessionSink` overrides this. - fn overlay(&self, spec: &RenderSpec, _ttl_ms: Option) { - self.render_widget(spec); + fn overlay(&self, spec: &RenderSpec, _ttl_ms: Option, effect: Option<&EffectSpec>) { + self.render_widget(spec, effect); } /// A render addressed to a named, persistent panel (008-grid-tui, FR-008). Defaults to drawing /// inline via [`ReplOutput::render_widget`] — the inline REPL has no panel column, so targeted /// renders still appear in the chat flow (back-compat, FR-008 scenario 3). The full-screen TUI's /// `SessionSink` overrides this to upsert the panel beside chat. fn panel_update(&self, _id: &str, spec: &RenderSpec) { - self.render_widget(spec); + self.render_widget(spec, None); } /// One panel-lifecycle effect (008-grid-tui, US2): create/replace (optionally with a TTL), /// remove, or clear. The default routes upserts to [`ReplOutput::panel_update`] and ignores @@ -576,6 +576,7 @@ pub async fn run_exchange( // target: inline into chat, or upserted into a named panel (008-grid-tui, FR-008). The // model still receives only `result.content` (the text summary), never the art (FR-023). if let Some(spec) = &result.render_spec { + let fx = result.inline_effect.as_ref(); match &result.render_target { // Legacy results routed panels via `render_target`; honor them for back-compat. crate::render_spec::RenderTarget::Panel { id } => output.panel_update(id, spec), @@ -583,9 +584,9 @@ pub async fn run_exchange( // inline, which is the inline REPL's honest degrade — it scrolls, so it has no // surface to take over. The full-screen front-end overrides it. crate::render_spec::RenderTarget::Overlay { ttl_ms } => { - output.overlay(spec, *ttl_ms) + output.overlay(spec, *ttl_ms, fx) } - crate::render_spec::RenderTarget::Inline => output.render_widget(spec), + crate::render_spec::RenderTarget::Inline => output.render_widget(spec, fx), } } // Then each panel-lifecycle effect, in order (008-grid-tui, US2). @@ -955,9 +956,12 @@ pub async fn run_repl( // upcoming full-screen TUI, where a tick-driven redraw needs no cursor-reclaim hack — see // docs/grid-tui-plan.md §5.) if config.mascot { - output.render_widget(&RenderSpec::Sprite { - spec: crate::viz::bee::sprite(), - }); + output.render_widget( + &RenderSpec::Sprite { + spec: crate::viz::bee::sprite(), + }, + None, + ); } output.info(&format!( @@ -1233,7 +1237,7 @@ mod tests { fn info(&self, msg: &str) { self.lines.lock().unwrap().push(format!("INFO {msg}")); } - fn render_widget(&self, spec: &RenderSpec) { + fn render_widget(&self, spec: &RenderSpec, _effect: Option<&EffectSpec>) { self.widgets.lock().unwrap().push(spec.clone()); } } diff --git a/src/repl/terminal.rs b/src/repl/terminal.rs index 5230742..a3fa9d9 100644 --- a/src/repl/terminal.rs +++ b/src/repl/terminal.rs @@ -17,7 +17,7 @@ use rustyline::ExternalPrinter; use tokio::task::JoinHandle; use super::ReplOutput; -use crate::render_spec::{AnimationSpec, RenderSpec}; +use crate::render_spec::{AnimationSpec, EffectSpec, RenderSpec}; use crate::tools::ToolResult; use crate::viz::theme::Role; use crate::viz::{animator, glyph, palette, sprite_render}; @@ -331,7 +331,7 @@ impl ReplOutput for TerminalOutput { self.emit(&self.role(Role::Accent, msg)); // accent — user's steering nudge } - fn render_widget(&self, spec: &RenderSpec) { + fn render_widget(&self, spec: &RenderSpec, _effect: Option<&EffectSpec>) { // Sprites/animations use the hand-rolled half-block renderer (truecolor, Slice 2); every other // widget goes through the headless ratatui pipeline (FR-025). Each row goes through the same // `ExternalPrinter` path as every other line — no alt-screen, no raw mode (SC-011). @@ -527,7 +527,7 @@ mod tests { let (t, _buf) = term(); t.busy_start(); assert_eq!(t.kind.load(Ordering::SeqCst), KIND_SPINNER); - t.render_widget(&RenderSpec::Animation { spec: tiny_anim() }); + t.render_widget(&RenderSpec::Animation { spec: tiny_anim() }, None); assert_eq!( t.kind.load(Ordering::SeqCst), KIND_ANIM, @@ -540,7 +540,7 @@ mod tests { async fn animation_reclaims_all_its_rows() { // SC-016(c): a 2-row animation, once stopped, leaves 2 rows for the next output to reclaim. let (t, buf) = term(); - t.render_widget(&RenderSpec::Animation { spec: tiny_anim() }); + t.render_widget(&RenderSpec::Animation { spec: tiny_anim() }, None); t.stop_active(); assert_eq!( t.reclaim_rows.load(Ordering::SeqCst), diff --git a/src/session/event.rs b/src/session/event.rs index 648277c..aecbd76 100644 --- a/src/session/event.rs +++ b/src/session/event.rs @@ -9,7 +9,7 @@ use bee_core::AuditEvent; use serde_json::Value; -use crate::render_spec::RenderSpec; +use crate::render_spec::{EffectSpec, RenderSpec}; use crate::tools::ToolResult; /// One thing the session core emitted, mirroring one [`crate::repl::ReplOutput`] callback. @@ -27,7 +27,10 @@ pub enum SessionEvent { audit: Vec, }, /// A visualization to draw inline in the chat flow (`render_widget`). - RenderWidget { spec: RenderSpec }, + RenderWidget { + spec: RenderSpec, + effect: Option, + }, /// A render addressed to a named, persistent panel (`panel_update`, 008-grid-tui US2). The TUI /// upserts `spec` into panel `id` beside chat — same id replaces in place (FR-008/009). PanelUpdate { id: String, spec: RenderSpec }, diff --git a/src/session/mod.rs b/src/session/mod.rs index 77dcb89..12af27e 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -17,7 +17,7 @@ pub use event::SessionEvent; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; -use crate::render_spec::RenderSpec; +use crate::render_spec::{EffectSpec, RenderSpec}; use crate::repl::ReplOutput; use crate::tools::ToolResult; @@ -86,14 +86,18 @@ impl ReplOutput for SessionSink { fn busy_stop(&self) { self.emit(SessionEvent::TurnDone); } - fn overlay(&self, spec: &RenderSpec, ttl_ms: Option) { + fn overlay(&self, spec: &RenderSpec, ttl_ms: Option, effect: Option<&EffectSpec>) { self.emit(SessionEvent::Overlay { spec: spec.clone(), ttl_ms, }); + let _ = effect; // overlay effects are handled by the overlay subsystem } - fn render_widget(&self, spec: &RenderSpec) { - self.emit(SessionEvent::RenderWidget { spec: spec.clone() }); + fn render_widget(&self, spec: &RenderSpec, effect: Option<&EffectSpec>) { + self.emit(SessionEvent::RenderWidget { + spec: spec.clone(), + effect: effect.cloned(), + }); } fn panel_update(&self, id: &str, spec: &RenderSpec) { self.emit(SessionEvent::PanelUpdate { diff --git a/src/tools.rs b/src/tools.rs index f664b30..e356fa7 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -18,7 +18,7 @@ pub mod files; pub mod render; pub mod skill; -use crate::render_spec::{PanelOp, RenderSpec, RenderTarget}; +use crate::render_spec::{EffectSpec, PanelOp, RenderSpec, RenderTarget}; /// The default tool set advertised to the model (contracts/scenario-schema.md). The CTF terminal /// tools (`submit_flag`, `give_up`) are **not** here — a scenario opts into them via its `tools` @@ -82,6 +82,9 @@ pub struct ToolResult { /// never sees these, only the text summary in `content`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub panel_ops: Vec, + /// The effect the agent attached to the inline widget via `.effect()` (009 FR-022). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inline_effect: Option, } impl ToolResult { @@ -97,6 +100,7 @@ impl ToolResult { render_spec: None, render_target: RenderTarget::Inline, panel_ops: Vec::new(), + inline_effect: None, } } @@ -112,6 +116,7 @@ impl ToolResult { render_spec: None, render_target: RenderTarget::Inline, panel_ops: Vec::new(), + inline_effect: None, } } @@ -137,10 +142,12 @@ impl ToolResult { summary: impl Into, inline: Option, panel_ops: Vec, + inline_effect: Option, ) -> Self { ToolResult { render_spec: inline, panel_ops, + inline_effect, ..ToolResult::ok(summary) } } diff --git a/src/tools/render.rs b/src/tools/render.rs index 5de9328..3a40502 100644 --- a/src/tools/render.rs +++ b/src/tools/render.rs @@ -22,45 +22,79 @@ const MAX_OPERATIONS: u64 = 10_000; /// The Rhai primer + function list embedded in the tool's schema so the model has the API surface in /// its tool definition (spec Assumption). const SCHEMA_DESC: &str = "\ -Render a visualization by writing a short Rhai script that ends with render(widget). Rhai is \ -JavaScript-adjacent: `let x = 5;`, `for i in 0..n {}`, `[1,2,3]` arrays, method calls like \ -`chart.bar(\"label\", 42)`. Integers are i64, floats f64. No I/O, filesystem, or network is \ -available — only these drawing functions:\n\ - bar_chart(title) -> chart; chart.bar(label, value); chart.color(name); chart.x_label(s); chart.y_label(s)\n\ - line_chart(title) -> chart; let s = chart.series(label); s.point(x, y)\n\ - sparkline(title, [ints]) -> chart\n\ - table(title) -> t; t.header([cols]); t.row([cells]); t.row_colored([cells], color)\n\ - gauge(title, value_0_to_1) -> g; g.label(s); g.color(name)\n\ +Render a visualization by writing a short Rhai script that ends with render(widget).\n\ +\n\ +LANGUAGE: Rhai (JavaScript-like). `let x = 5;`, `for i in 0..n {}`, `[1,2,3]` arrays. \ +Integers are i64, floats f64. No I/O or network.\n\ +\n\ +== CHARTS ==\n\ + bar_chart(title) -> c; c.bar(label, value); c.color(name); c.x_label(s); c.y_label(s)\n\ + line_chart(title) -> c; let s = c.series(label); s.point(x, y)\n\ + scatter(title) -> c; let s = c.series(label); s.point(x, y)\n\ + area_chart(title) -> c; let s = c.series(label); s.point(x, y); c.color(name)\n\ + heatmap(title) -> h; h.row(label, [floats]); h.color(name)\n\ + sparkline(title, [ints])\n\ +\n\ +== DATA ==\n\ + table(title) -> t; t.header([col1, col2, ...]); t.row([val1, val2, ...])\n\ + gauge(title, 0.0..1.0) -> g; g.label(s); g.color(name)\n\ dots(title) -> d; d.pass(label); d.fail(label); d.skip(label)\n\ - text(content) -> t; t.style(name); t.bold(); t.dim()\n\ - markdown(source) -> m // headings, lists, emphasis, links, code — styled by the operator's theme\n\ - ascii_art([lines]); separator()\n\ - vsplit() / hsplit() -> layout; layout.add(widget) (max nesting depth 3)\n\ + log_tail(title) -> l; l.line(text); l.line(text, level); l.max_rows(n) // newest lines win; level: error|warn|info|debug\n\ +\n\ +== TEXT & LAYOUT ==\n\ + text(content) -> t; t.color(name); t.bold(); t.dim()\n\ + markdown(source)\n\ + ascii_art([line1, line2, ...]); separator()\n\ + vsplit() / hsplit() -> layout; layout.add(widget) (max nesting 3)\n\ +\n\ +== PIXEL ART ==\n\ palette() -> p; p.set(\"K\", \"#1A1A1A\"); p.set(\".\", \"transparent\")\n\ - sprite(w, h, p) -> s; s.paint([\"..KK..\", ...]); s.set(x, y, color); s.fill(color) (max 32x32)\n\ + sprite(w, h, palette) -> s; s.paint([\"..KK..\", ...]); s.set(x, y, color); s.fill(color) (max 32x32)\n\ animation(ms) -> a; a.add(sprite); a.bounce(true); a.cycles(n) (max 16 frames, 50-1000ms)\n\ - bee_sprite(); bee_animation() // the project mascot\n\ - render(widget) // draw inline in the chat flow\n\ - render_to(panel_id, widget) // draw to a named, persistent side panel (id: 1-32 of [a-z0-9_-]);\n\ - // re-rendering the same id replaces that panel in place\n\ - render_to_ttl(panel_id, widget, ttl_ms) // same, but the panel auto-expires after ttl_ms\n\ - remove_panel(panel_id) // close one panel and reclaim its space\n\ - clear_panels() // close every panel\n\ - render_fullscreen(widget) // take over the whole chat area briefly\n\ - render_fullscreen_ttl(widget, ttl_ms) // same, with your own lifetime (capped by the operator)\n\ -Panel hygiene: panels persist until removed and share one column, so each extra panel shrinks the \ -rest. Reuse one id for updates, give short-lived output a TTL, and remove_panel/clear_panels when \ -done. You may address several panels in a single script.\n\ -Transitions (optional): widget.effect(e) sets how the widget arrives. Without one you still get a \ -sensible default — reach for these only when the motion means something.\n\ + bee_sprite(); bee_animation()\n\ +\n\ +== RENDERING ==\n\ + render(widget) // inline in chat\n\ + render_to(panel_id, widget) // persistent side panel (id: 1-32 chars [a-z0-9_-])\n\ + render_to_ttl(panel_id, widget, ttl_ms) // side panel that auto-expires\n\ + remove_panel(panel_id); clear_panels()\n\ + render_fullscreen(widget); render_fullscreen_ttl(widget, ttl_ms)\n\ +\n\ +== TRANSITIONS (optional) ==\n\ + widget.effect(fade_in(ms)) // attach before render()\n\ fade_in(ms) / fade_out(ms); dissolve_in(ms) / dissolve_out(ms); evolve_in(ms) / evolve_out(ms)\n\ - slide_in(dir, ms) / slide_out(dir, ms); sweep_in(dir, ms) / sweep_out(dir, ms) (dir: left/right/top/bottom)\n\ - pulse(color, ms) // one flash, for a status change; glow(ms) // gentle breathing\n\ -Durations clamp to 100-2000ms and unknown directions become \"left\" — none of that is an error. \ -The operator may have disabled animation or restricted how much screen you get; your render still \ -succeeds and the result tells you if it was downgraded.\n\ -Colors: honey, pollen, sting, smoke, royal (or basic ANSI names). Caps: <=500 total elements. \ -The model receives a text summary of what was drawn, not the pixels."; + slide_in(dir, ms) / slide_out(dir, ms); sweep_in(dir, ms) / sweep_out(dir, ms)\n\ + pulse(color, ms); glow(ms) // dir: left/right/top/bottom; ms: 100-2000\n\ +\n\ +== EXAMPLES ==\n\ +Bar chart:\n\ + let c = bar_chart(\"Sales\"); c.bar(\"Q1\", 100); c.bar(\"Q2\", 150); c.bar(\"Q3\", 80); render(c);\n\ +\n\ +Line chart with effect:\n\ + let c = line_chart(\"CPU\"); let s = c.series(\"load\");\n\ + s.point(0, 10); s.point(1, 45); s.point(2, 30); s.point(3, 70);\n\ + c.effect(fade_in(300)); render(c);\n\ +\n\ +Table:\n\ + let t = table(\"Results\"); t.header([\"Name\", \"Score\", \"Status\"]);\n\ + t.row([\"Alice\", \"95\", \"pass\"]); t.row([\"Bob\", \"72\", \"pass\"]); render(t);\n\ +\n\ +Heatmap:\n\ + let h = heatmap(\"Activity\"); h.row(\"Mon\", [0.1, 0.5, 0.9, 0.3]);\n\ + h.row(\"Tue\", [0.8, 0.2, 0.4, 0.7]); render(h);\n\ +\n\ +Scatter plot:\n\ + let c = scatter(\"Clusters\"); let s = c.series(\"A\");\n\ + s.point(1, 2); s.point(3, 4); s.point(2, 5); render(c);\n\ +\n\ +Gauge:\n\ + let g = gauge(\"Disk\", 0.73); g.label(\"73%\"); g.color(\"accent\"); render(g);\n\ +\n\ +Side panel (reuse id to update in place):\n\ + let g = gauge(\"Progress\", 0.5); render_to(\"status\", g);\n\ +\n\ +Colors: accent, success, error, info, honey, pollen, sting, smoke, royal (or ANSI names). \ +Caps: <=500 total elements. You receive a text summary of what was drawn, not the pixels."; /// The text summary the model gets back: what was drawn and where it went. Never pixels (FR-023). fn summarize(outcome: &crate::render_api::RenderOutcome) -> String { @@ -276,17 +310,21 @@ impl Tool for RenderTool { // `render_spec`/`render_target` pair when a script commits both — it is the more // specific request. Panel ops ride alongside either way. match outcome.overlay { - Some((spec, ttl_ms, _)) => ToolResult { + Some((spec, ttl_ms, effect)) => ToolResult { panel_ops: outcome.panel_ops, + inline_effect: effect, ..ToolResult::rendered_to( summary, spec, crate::render_spec::RenderTarget::Overlay { ttl_ms }, ) }, - None => { - ToolResult::rendered_with_ops(summary, outcome.inline, outcome.panel_ops) - } + None => ToolResult::rendered_with_ops( + summary, + outcome.inline, + outcome.panel_ops, + outcome.inline_effect, + ), } } Err(e) => { diff --git a/src/transcript.rs b/src/transcript.rs index ccfcca7..db51a2d 100644 --- a/src/transcript.rs +++ b/src/transcript.rs @@ -326,6 +326,7 @@ pub fn tool_result_from_output( render_spec: None, render_target: crate::render_spec::RenderTarget::Inline, panel_ops: Vec::new(), + inline_effect: None, } } diff --git a/src/tui/app.rs b/src/tui/app.rs index 62b56d0..f5acf05 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -19,11 +19,13 @@ use crate::config::VisualConfig; use crate::render_spec::RenderSpec; use crate::session::SessionEvent; -/// Which region has keyboard focus. (Panels focus arrives with US2.) +/// Which region has keyboard focus. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Focus { Input, Chat, + /// The panel column (or the panel overlay): j/k select, Space collapses, x closes. + Panels, } /// Whether an assistant turn is in flight (drives the spinner / "thinking" line). @@ -111,6 +113,15 @@ pub struct App { /// Whether the panel column is currently on screen, so its arrival animates once rather than on /// every frame it happens to be visible (009 US5). pub column_shown: bool, + /// The model the session is talking to, shown in the header. Empty when unknown (tests). + pub model_id: String, + /// Which panel the operator has selected while the panel column has focus (index into the + /// registry's insertion order, clamped by every consumer — panels come and go under it). + pub panel_sel: usize, + /// Count of session events received (deltas, tool calls, results). The header's working + /// indicator takes its animation phase from this, so it moves exactly when data is flowing and + /// holds still when the turn has stalled — motion as a report, not as decoration. + pub activity: u64, } impl App { @@ -144,9 +155,18 @@ impl App { // every 008 test does) shows settled chrome rather than the first frame of a fade. chrome_cues: Vec::new(), column_shown: false, + model_id: String::new(), + panel_sel: 0, + activity: 0, } } + /// The same session with the model's id in the header (008 header context). + pub fn with_model(mut self, id: impl Into) -> Self { + self.model_id = id.into(); + self + } + /// The same session under an explicit visual configuration (009). Kept separate from /// [`App::new`] so 008's tests, which have no opinion about motion, stay untouched. pub fn with_visual(mut self, visual: VisualConfig) -> Self { @@ -241,10 +261,20 @@ impl App { }) } + /// Whether the panel region is on screen and can take focus: a populated column in `TwoPane`, + /// or the open overlay in `SinglePane`. + pub fn panels_reachable(&self) -> bool { + !self.panels.is_empty() && (self.layout_mode == LayoutMode::TwoPane || self.panels_visible) + } + fn cycle_focus(&mut self) { self.focus = match self.focus { Focus::Input => Focus::Chat, + // Panels join the cycle only while they are actually on screen — Tab never lands focus + // on a region the operator cannot see. + Focus::Chat if self.panels_reachable() => Focus::Panels, Focus::Chat => Focus::Input, + Focus::Panels => Focus::Input, }; } @@ -364,9 +394,49 @@ fn handle_key(app: &mut App, key: KeyEvent) { app.cycle_focus(); return; } + // Panels focus with no panels left (pruned, cleared, TTL'd) falls back to input rather than + // routing keys at a region that isn't there. + if app.focus == Focus::Panels && !app.panels_reachable() { + app.focus = Focus::Input; + } match app.focus { Focus::Input => handle_input_key(app, key), Focus::Chat => handle_chat_key(app, key), + Focus::Panels => handle_panels_key(app, key), + } +} + +/// Keys while the panel column (or overlay) has focus: vertical selection, collapse, close. +fn handle_panels_key(app: &mut App, key: KeyEvent) { + let len = app.panels.len(); + app.panel_sel = app.panel_sel.min(len.saturating_sub(1)); + match key.code { + KeyCode::Char('q') => app.should_quit = true, + KeyCode::Char('?') => app.help_open = true, + KeyCode::Up | KeyCode::Char('k') => app.panel_sel = app.panel_sel.saturating_sub(1), + KeyCode::Down | KeyCode::Char('j') => { + app.panel_sel = (app.panel_sel + 1).min(len.saturating_sub(1)) + } + // Collapse is operator state: it survives the agent's upserts, so a panel the operator + // folded away stays folded no matter how often its content refreshes. + KeyCode::Char(' ') | KeyCode::Enter => app.panels.toggle_collapse_at(app.panel_sel), + KeyCode::Char('x') => { + app.panels.remove_at(app.panel_sel); + if app.panels.is_empty() { + // The region just vanished from under the focus. + app.panels_visible = false; + app.focus = Focus::Input; + } else { + app.panel_sel = app.panel_sel.min(app.panels.len() - 1); + } + } + KeyCode::Char('i') => app.focus = Focus::Input, + // Esc (or `p` where the overlay is what put panels on screen) leaves the region entirely. + KeyCode::Char('p') | KeyCode::Esc => { + app.panels_visible = false; + app.focus = Focus::Input; + } + _ => {} } } @@ -419,7 +489,14 @@ fn handle_chat_key(app: &mut App, key: KeyEvent) { KeyCode::Char('?') => app.help_open = true, // Toggle the panel overlay (single-pane layouts, US3 T036). Bound in chat focus so `p` stays // an ordinary character while typing — the contract's "any" context, minus the input line. - KeyCode::Char('p') => app.panels_visible = !app.panels_visible, + // Opening it moves focus to the panels: the overlay is what the operator just asked to + // drive, and j/k/Space/x should work immediately rather than after a Tab. + KeyCode::Char('p') => { + app.panels_visible = !app.panels_visible; + if app.panels_reachable() && app.panels_visible { + app.focus = Focus::Panels; + } + } // Yank the newest prose message to the system clipboard via OSC 52 (FR-016, US3 T035). KeyCode::Char('y') => app.yank = app.newest_text(), // Esc closes the overlay first, before falling through to anything else. @@ -442,6 +519,8 @@ fn handle_chat_key(app: &mut App, key: KeyEvent) { } fn handle_session(app: &mut App, ev: SessionEvent) { + // Every session event is data arriving; the header's working indicator phases off this count. + app.activity = app.activity.wrapping_add(1); match ev { SessionEvent::AssistantDelta(s) => { // FR-019: the model's reply to a message the operator sent *since* the overlay appeared @@ -494,8 +573,8 @@ fn handle_session(app: &mut App, ev: SessionEvent) { Chrome::ToolResult }); } - SessionEvent::RenderWidget { spec } => { - app.chat.push(ChatMessage::widget(spec)); + SessionEvent::RenderWidget { spec, effect } => { + app.chat.push(ChatMessage::widget_with_effect(spec, effect)); app.autoscroll(); } // A full-screen takeover (009 US3). It reached here only because the visual gate admitted @@ -614,6 +693,88 @@ mod tests { assert!(a.should_quit); } + #[test] + fn tab_reaches_panels_only_when_they_are_on_screen() { + // No panels: the cycle is input · chat, exactly as before panels focus existed. + let mut bare = App::new(120, 24); + update(&mut bare, Message::key(KeyCode::Tab)); + assert_eq!(bare.focus, Focus::Chat); + update(&mut bare, Message::key(KeyCode::Tab)); + assert_eq!(bare.focus, Focus::Input); + + // A populated TwoPane column joins the cycle. + let mut with = App::new(120, 24); + with.panels + .upsert("m", crate::render_spec::RenderSpec::Separator); + update(&mut with, Message::key(KeyCode::Tab)); + assert_eq!(with.focus, Focus::Chat); + update(&mut with, Message::key(KeyCode::Tab)); + assert_eq!(with.focus, Focus::Panels); + update(&mut with, Message::key(KeyCode::Tab)); + assert_eq!(with.focus, Focus::Input); + + // SinglePane with the overlay closed: panels are off screen, so Tab skips them. + let mut narrow = App::new(80, 24); + narrow + .panels + .upsert("m", crate::render_spec::RenderSpec::Separator); + update(&mut narrow, Message::key(KeyCode::Tab)); + update(&mut narrow, Message::key(KeyCode::Tab)); + assert_eq!(narrow.focus, Focus::Input); + } + + #[test] + fn panels_focus_selects_collapses_and_closes() { + let mut a = App::new(120, 24); + a.panels + .upsert("one", crate::render_spec::RenderSpec::Separator); + a.panels + .upsert("two", crate::render_spec::RenderSpec::Separator); + update(&mut a, Message::key(KeyCode::Tab)); + update(&mut a, Message::key(KeyCode::Tab)); + assert_eq!(a.focus, Focus::Panels); + + // j moves the selection down, k back up, both clamped. + update(&mut a, Message::char('j')); + assert_eq!(a.panel_sel, 1); + update(&mut a, Message::char('j')); + assert_eq!(a.panel_sel, 1, "clamped at the last panel"); + update(&mut a, Message::char('k')); + assert_eq!(a.panel_sel, 0); + + // Space folds the selected panel; a fresh upsert must not unfold it (operator state). + update(&mut a, Message::char(' ')); + assert!(a.panels.iter_panels().next().unwrap().collapsed); + a.panels + .upsert("one", crate::render_spec::RenderSpec::Separator); + assert!( + a.panels.iter_panels().next().unwrap().collapsed, + "an agent upsert cannot unfold what the operator folded" + ); + + // x closes the selected panel; closing the last one returns focus to input. + update(&mut a, Message::char('x')); + assert_eq!(a.panels.len(), 1); + assert_eq!(a.focus, Focus::Panels); + update(&mut a, Message::char('x')); + assert!(a.panels.is_empty()); + assert_eq!(a.focus, Focus::Input); + } + + #[test] + fn opening_the_overlay_focuses_panels_and_esc_leaves() { + let mut a = App::new(80, 24); // SinglePane + a.panels + .upsert("m", crate::render_spec::RenderSpec::Separator); + update(&mut a, Message::key(KeyCode::Tab)); // chat focus, where `p` lives + update(&mut a, Message::char('p')); + assert!(a.panels_visible); + assert_eq!(a.focus, Focus::Panels, "the overlay opens ready to drive"); + update(&mut a, Message::key(KeyCode::Esc)); + assert!(!a.panels_visible); + assert_eq!(a.focus, Focus::Input); + } + #[test] fn q_quits_from_chat_but_types_in_input() { let mut a = app(); @@ -937,6 +1098,7 @@ mod tests { &mut a, Message::session(SessionEvent::RenderWidget { spec: text_spec("inline"), + effect: None, }), ); assert_eq!(a.chat.len(), 1, "inline render appends a chat widget"); diff --git a/src/tui/chat.rs b/src/tui/chat.rs index e07be29..8796911 100644 --- a/src/tui/chat.rs +++ b/src/tui/chat.rs @@ -7,7 +7,7 @@ use ratatui::text::Line; -use crate::render_spec::RenderSpec; +use crate::render_spec::{EffectSpec, RenderSpec}; /// Who produced a chat message. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -29,7 +29,7 @@ pub enum Body { /// Markdown the *sender* declared as markdown — a skill's instructions, say. Distinct from /// `Text` because guessing is unsafe: a tool result containing `**` is data, not emphasis (010). Markdown(String), - Widget(Box), + Widget(Box, Option), } /// One entry in the transcript view. @@ -77,7 +77,17 @@ impl ChatMessage { pub fn widget(spec: RenderSpec) -> Self { ChatMessage { role: Role::Tool, - body: Body::Widget(Box::new(spec)), + body: Body::Widget(Box::new(spec), None), + done: true, + cache: None, + } + } + + /// An inline widget with an agent-requested effect (009 FR-022). + pub fn widget_with_effect(spec: RenderSpec, effect: Option) -> Self { + ChatMessage { + role: Role::Tool, + body: Body::Widget(Box::new(spec), effect), done: true, cache: None, } @@ -133,7 +143,7 @@ impl ChatMessage { } // A widget has no prose to append to, and a markdown block arrives whole — appending to // one mid-render would re-parse a document that was never partial. - Body::Widget(_) | Body::Markdown(_) => false, + Body::Widget(..) | Body::Markdown(_) => false, } } diff --git a/src/tui/markdown.rs b/src/tui/markdown.rs index 536610e..636825f 100644 --- a/src/tui/markdown.rs +++ b/src/tui/markdown.rs @@ -16,10 +16,11 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use tui_markdown::{Options, StyleSheet}; +use tui_markdown::{BuiltinCodeTheme, Options, StyleSheet}; use super::theme_bridge::{dim_style, role_style}; -use crate::viz::theme::Role; +use crate::viz::palette; +use crate::viz::theme::{self, Role}; /// bee's markdown look, expressed in semantic roles (design-system §1). /// @@ -60,14 +61,59 @@ impl StyleSheet for BeeStyleSheet { } } +/// The bundled syntax-highlighting theme tonally nearest the active bee theme, so a highlighted +/// code block sits inside the conversation instead of arriving from some other product. This is +/// what answers 005's "theme-blind highlighter" objection: the pairing follows the theme, and +/// `NO_COLOR` strips the highlighter's colors entirely (see [`render`]). +fn code_theme() -> BuiltinCodeTheme { + match theme::active_theme().name.as_str() { + // The one light flavor gets a light code theme — dark-on-dark syntax colors on a light + // terminal would be the exact clash 005 was guarding against. + "catppuccin-latte" => BuiltinCodeTheme::InspiredGitHub, + "catppuccin-mocha" | "catppuccin-frappe" | "catppuccin-macchiato" => { + BuiltinCodeTheme::Base16MochaDark + } + // The two warm-toned themes get the warm base16; the cool ones fall through to Ocean. + "dracula" | "gruvbox" => BuiltinCodeTheme::Base16EightiesDark, + _ => BuiltinCodeTheme::Base16OceanDark, + } +} + /// Render `md` as chat rows, each exactly one row tall at `width` columns. pub fn render(md: &str, width: u16) -> Vec> { - let text = tui_markdown::from_str_with_options(md, &Options::new(BeeStyleSheet)); + let options = Options::new(BeeStyleSheet).code_theme(code_theme()); + let text = tui_markdown::from_str_with_options(md, &options); let width = width.max(1); - text.lines + let lines = text + .lines + .into_iter() + .flat_map(|line| wrap_line(line, width)); + if palette::is_color_enabled() { + lines.collect() + } else { + // Highlighter colors come from syntect, not the theme bridge, so `NO_COLOR` has to be + // enforced here: color is dropped, weight and italics survive — they are not color. + lines.map(strip_colors).collect() + } +} + +/// A line with every foreground/background color removed, modifiers kept. +fn strip_colors(line: Line<'static>) -> Line<'static> { + let style = Style { + fg: None, + bg: None, + ..line.style + }; + let spans: Vec> = line + .spans .into_iter() - .flat_map(|line| wrap_line(line, width)) - .collect() + .map(|mut s| { + s.style.fg = None; + s.style.bg = None; + s + }) + .collect(); + Line::from(spans).style(style) } /// Break one styled line into rows of at most `width` columns, preserving each span's style. @@ -144,6 +190,52 @@ mod tests { line.spans.iter().map(|s| s.content.as_ref()).collect() } + #[test] + fn a_recognized_fence_is_highlighted_and_no_color_strips_it() { + // NO_COLOR is process-global, so both states live in one serial test (mirrors the + // theme_bridge and palette tests in this binary). + let md = "```rust\nfn main() { let answer = 42; }\n```\n"; + let distinct_fgs = |lines: &[Line<'_>]| { + lines + .iter() + .flat_map(|l| l.spans.iter()) + .filter_map(|s| s.style.fg.map(|c| format!("{c:?}"))) + .collect::>() + .len() + }; + + // Color on: a recognized language gets real per-token colors — more than one foreground. + std::env::remove_var("NO_COLOR"); + let lit = render(md, 60); + assert!( + distinct_fgs(&lit) > 1, + "expected >1 syntax colors, got {}:\n{lit:?}", + distinct_fgs(&lit) + ); + let joined: String = lit.iter().map(text_of).collect::>().join("\n"); + assert!( + joined.contains("fn main()"), + "code text survives:\n{joined}" + ); + + // NO_COLOR: the highlighter's colors are stripped here in `render`, because they come from + // syntect rather than the theme bridge — the fence must degrade to monochrome like + // everything else (FR-014). + std::env::set_var("NO_COLOR", "1"); + let plain = render(md, 60); + assert_eq!( + distinct_fgs(&plain), + 0, + "no colors under NO_COLOR:\n{plain:?}" + ); + let joined: String = plain.iter().map(text_of).collect::>().join("\n"); + assert!( + joined.contains("fn main()"), + "…but never the text:\n{joined}" + ); + std::env::remove_var("NO_COLOR"); + } + #[test] fn emphasis_becomes_style_rather_than_literal_punctuation() { let lines = render("plain **bold** tail\n", 40); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 0fde300..d935245 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -73,7 +73,9 @@ pub async fn run( let mut terminal = term::init(); let mut restore_guard = term::RestoreGuard::terminal(); let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); - let mut app = App::new(cols, rows).with_visual(config.visual); + let mut app = App::new(cols, rows) + .with_visual(config.visual) + .with_model(model.id().to_string()); // bee's own opening: the header fades up and the footer slides in behind it (US5 §1, FR-026). app.chrome_cues .extend([effects::Chrome::Header, effects::Chrome::Footer]); diff --git a/src/tui/panels.rs b/src/tui/panels.rs index 72edc5f..98d06bd 100644 --- a/src/tui/panels.rs +++ b/src/tui/panels.rs @@ -66,6 +66,10 @@ pub struct Panel { /// The transition owed at the next render, cleared once registered. pub pending: Option, pub fx: EffectSlot, + /// Folded down to its label row by the operator (Space in panels focus). **Operator state**: + /// upserts replace the spec but never unfold a panel — the agent doesn't get to override what + /// the operator chose not to look at. + pub collapsed: bool, } /// The ordered set of live panels. `Default` is empty (US1 has none). @@ -148,6 +152,7 @@ impl PanelRegistry { started: Some(now), ..EffectSlot::default() }, + collapsed: false, }), } } @@ -168,6 +173,25 @@ impl PanelRegistry { self.panels.retain(|p| p.id != id); } + /// Toggle panel `idx`'s collapsed state (insertion order); a no-op out of range. + pub fn toggle_collapse_at(&mut self, idx: usize) { + if let Some(p) = self.panels.get_mut(idx) { + p.collapsed = !p.collapsed; + } + } + + /// Remove the panel at `idx` (insertion order); a no-op out of range. + pub fn remove_at(&mut self, idx: usize) { + if idx < self.panels.len() { + self.panels.remove(idx); + } + } + + /// Panels in insertion order, with their full state — what the renderer walks. + pub fn iter_panels(&self) -> impl Iterator { + self.panels.iter() + } + /// Remove every panel. pub fn clear(&mut self) { self.panels.clear(); @@ -237,6 +261,15 @@ pub fn register_transition( return false; }; panel.fx.started = None; + // A log tail appends every few moments; dissolving the whole panel per append would be constant + // churn saying nothing. The new line arriving at the bottom *is* the report, so updates play no + // transition — the entrance (and any effect the agent explicitly requested) still does. + if pending == Transition::Update + && panel.fx.requested.is_none() + && matches!(panel.spec, RenderSpec::LogTail { .. }) + { + return false; + } // An agent-requested transition replaces the default for both create and replace, and plays over // the whole panel — the agent asked for *this* motion, not for a variation on the default. if let Some(spec) = panel.fx.requested.take() { diff --git a/src/tui/theme_bridge.rs b/src/tui/theme_bridge.rs index 6b18b07..89e4506 100644 --- a/src/tui/theme_bridge.rs +++ b/src/tui/theme_bridge.rs @@ -5,7 +5,7 @@ //! color is disabled it returns an unstyled `Style`, so the full-screen path stays legible in //! monochrome exactly like the inline REPL (FR-014 / SC-005; resolves analysis G2). -use ratatui::style::{Modifier, Style}; +use ratatui::style::{Color, Modifier, Style}; use crate::viz::buffer_render::theme_to_ratatui_color; use crate::viz::palette; @@ -31,6 +31,19 @@ pub fn dim_style() -> Style { role_style(Role::Dim) } +/// A filled badge — dark ink on the role's color, for the header's `bee` mark. Under `NO_COLOR` it +/// degrades to bold reverse video, which carries the same "this is a label, not text" weight on a +/// monochrome terminal. +pub fn badge_style(role: Role) -> Style { + if !palette::is_color_enabled() { + return Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED); + } + Style::default() + .fg(Color::Black) + .bg(theme_to_ratatui_color(theme::active_theme().get(role))) + .add_modifier(Modifier::BOLD) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/tui/view.rs b/src/tui/view.rs index cc2dc0c..f3c11ff 100644 --- a/src/tui/view.rs +++ b/src/tui/view.rs @@ -10,7 +10,8 @@ use ratatui::layout::{Alignment, Constraint, Layout, Position, Rect}; use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::{ - Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap, + Block, BorderType, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, + Wrap, }; use ratatui::Frame; @@ -18,8 +19,8 @@ use super::app::{App, Focus, LayoutMode, TurnState}; use super::chat::{Body, Role}; use super::effects::{self, Chrome}; use super::panels::{self, PanelArea}; -use super::theme_bridge::{dim_style, role_style, role_style_bold}; -use crate::render_spec::RenderSpec; +use super::theme_bridge::{badge_style, dim_style, role_style, role_style_bold}; +use crate::render_spec::{EffectSpec, RenderSpec}; use crate::viz::theme::Role as ThemeRole; /// Draw the whole UI for the current model state. @@ -151,17 +152,30 @@ fn render_takeover(app: &mut App, frame: &mut Frame<'_>, area: Rect) { /// narrow terminal can still see model-owned output. Toggled with `p`, closed with `p`/`Esc`. fn render_panel_overlay(app: &mut App, frame: &mut Frame<'_>, area: Rect) { let w = area.width.saturating_sub(4).clamp(20, 72); - let h = area.height.saturating_sub(4).max(5); + // Sized to the panels it holds (label row + content each, plus the popup border), not to the + // screen: a popup showing one gauge over a 40-row terminal was a window of empty space. + let content: u16 = app + .panels + .iter() + .map(|(_, spec)| { + crate::viz::buffer_render::spec_height(spec, w.saturating_sub(3)).saturating_add(1) + }) + .sum(); + let h = content + .saturating_add(2) + .clamp(5, area.height.saturating_sub(4).max(5)); let popup = center(area, w, h); frame.render_widget(Clear, popup); let block = Block::default() .borders(Borders::ALL) + .border_type(BorderType::Rounded) .border_style(role_style(ThemeRole::Info)) .title(Span::styled( - format!(" panels ({}) — p/Esc to close ", app.panels.len()), - role_style(ThemeRole::Info), - )); + format!(" panels ({}) ", app.panels.len()), + role_style_bold(ThemeRole::Info), + )) + .title_bottom(Span::styled(" p/Esc close ", dim_style())); let inner = block.inner(popup); frame.render_widget(block, popup); if inner.width > 0 && inner.height > 0 { @@ -169,14 +183,42 @@ fn render_panel_overlay(app: &mut App, frame: &mut Frame<'_>, area: Rect) { } } +/// The working indicator's frames: a three-cell comb filling with honey and draining again — bee's +/// own spinner, not the stock braille one. Its phase comes from [`App::activity`], not a clock: it +/// advances when session events arrive and freezes when the stream stalls, so the comb *is* a +/// report of data flowing rather than an animation asserting liveness the session may not have. +const WORKING_FRAMES: [&str; 6] = ["⬡⬡⬡", "⬢⬡⬡", "⬢⬢⬡", "⬢⬢⬢", "⬡⬢⬢", "⬡⬡⬢"]; + fn render_header(app: &App, frame: &mut Frame<'_>, area: Rect) { - let mut spans = vec![ - Span::styled("bee", role_style_bold(ThemeRole::Info)), - Span::styled(" full-screen", dim_style()), - ]; + let mut left = vec![Span::styled(" bee ", badge_style(ThemeRole::Info))]; + if !app.model_id.is_empty() { + left.push(Span::styled(format!(" {}", app.model_id), dim_style())); + } + let mut right: Vec> = Vec::new(); if app.turn == TurnState::Streaming { - spans.push(Span::styled(" ● working…", role_style(ThemeRole::Accent))); + // A motionless session gets a full, still comb: state shown, nothing moving (FR-006c's + // spirit — the operator turned motion off, and a phase that ticks per event is motion). + let cells = if app.visual.animations { + WORKING_FRAMES[app.activity as usize % WORKING_FRAMES.len()] + } else { + "⬢⬢⬢" + }; + right.push(Span::styled( + format!("{cells} working "), + role_style(ThemeRole::Info), + )); } + // Left context, right status, gap in between — one line, no border, position is the hierarchy. + let used: usize = left + .iter() + .chain(right.iter()) + .map(|s| s.content.chars().count()) + .sum(); + let mut spans = left; + spans.push(Span::raw( + " ".repeat((area.width as usize).saturating_sub(used)), + )); + spans.extend(right); frame.render_widget(Paragraph::new(Line::from(spans)), area); } @@ -184,26 +226,57 @@ fn render_footer(app: &App, frame: &mut Frame<'_>, area: Rect) { // The most useful keys (contracts/keybindings.md), trimmed to what actually fits so a narrow // terminal never truncates mid-hint. `p` is advertised only where the overlay is the *only* way // to see panels — exactly the case where it matters most. - let show_p = !app.panels.is_empty() && app.layout_mode == LayoutMode::SinglePane; - let mut parts: Vec<&str> = vec!["Enter send", "↑↓/PgUp·Dn scroll", "Tab focus"]; - if show_p { - parts.push("p panels"); - } - parts.extend(["y yank", "? help", "q quit"]); + // Hints follow the focus: the panel column has its own verbs, and advertising Enter-to-send + // while j/k/Space/x are what the keys actually do would be the footer lying. + let mut parts: Vec<(&str, &str)> = if app.focus == Focus::Panels { + vec![ + ("j/k", "select"), + ("Space", "collapse"), + ("x", "close"), + ("Tab", "focus"), + ("?", "help"), + ("q", "quit"), + ] + } else { + let show_p = !app.panels.is_empty() && app.layout_mode == LayoutMode::SinglePane; + let mut parts: Vec<(&str, &str)> = vec![ + ("Enter", "send"), + ("↑↓/PgUp·Dn", "scroll"), + ("Tab", "focus"), + ]; + if show_p { + parts.push(("p", "panels")); + } + parts.extend([("y", "yank"), ("?", "help"), ("q", "quit")]); + parts + }; - // Shed the least essential hints first; Enter/help/quit (and `p` when shown) always survive. + // Shed the least essential hints first; help/quit (and the destructive `x`) always survive. + // Width accounting mirrors the span layout below: " " lead, "key label", " " between hints. let width = area.width as usize; - let rendered = |parts: &[&str]| format!(" {} ", parts.join(" ")); - for droppable in ["y yank", "↑↓/PgUp·Dn scroll", "Tab focus"] { - if rendered(&parts).chars().count() <= width { + let hint_len = |parts: &[(&str, &str)]| { + 2 + parts + .iter() + .map(|(k, l)| k.chars().count() + 1 + l.chars().count()) + .sum::() + + parts.len().saturating_sub(1) * 3 + }; + for droppable in ["yank", "scroll", "select", "collapse", "focus"] { + if hint_len(&parts) <= width { break; } - parts.retain(|p| *p != droppable); + parts.retain(|(_, l)| *l != droppable); } - frame.render_widget( - Paragraph::new(Line::styled(rendered(&parts), dim_style())), - area, - ); + // Key in the accent, action dimmed: scannable as chords, quiet as a whole line. + let mut spans = vec![Span::raw(" ")]; + for (i, (key, label)) in parts.iter().enumerate() { + if i > 0 { + spans.push(Span::raw(" ")); + } + spans.push(Span::styled(*key, role_style(ThemeRole::Accent))); + spans.push(Span::styled(format!(" {label}"), dim_style())); + } + frame.render_widget(Paragraph::new(Line::from(spans)), area); } fn render_input(app: &App, frame: &mut Frame<'_>, area: Rect) { @@ -213,17 +286,30 @@ fn render_input(app: &App, frame: &mut Frame<'_>, area: Rect) { } else { dim_style() }; + // Rounded, untitled: the prompt glyph says what the box is, so a " input " label was chrome + // spent restating the obvious. Focus is carried by border color *and* the prompt's weight. let block = Block::default() .borders(Borders::ALL) - .border_style(border) - .title(Span::styled(" input ", dim_style())); + .border_type(BorderType::Rounded) + .border_style(border); let inner = block.inner(area); frame.render_widget(block, area); + // Prompt column first, text beside it — separate rects so the wrap and cursor arithmetic on the + // text are untouched by the marker. + let [prompt_col, text_area] = + Layout::horizontal([Constraint::Length(2), Constraint::Min(1)]).areas(inner); + let prompt = if focused { + role_style_bold(ThemeRole::Accent) + } else { + dim_style() + }; + frame.render_widget(Paragraph::new(Line::styled("❯", prompt)), prompt_col); + let text = app.input.text(); frame.render_widget( Paragraph::new(text.as_str()).wrap(Wrap { trim: false }), - inner, + text_area, ); // The cursor follows its real position in the buffer (010) — ↑/↓ can now move between the soft @@ -231,8 +317,8 @@ fn render_input(app: &App, frame: &mut Frame<'_>, area: Rect) { // Soft *wrapping* of one long line is still not tracked; only hard newlines are. if focused { let (row, col) = app.input.line_col(); - let x = inner.x + (col as u16).min(inner.width.saturating_sub(1)); - let y = inner.y + (row as u16).min(inner.height.saturating_sub(1)); + let x = text_area.x + (col as u16).min(text_area.width.saturating_sub(1)); + let y = text_area.y + (row as u16).min(text_area.height.saturating_sub(1)); frame.set_cursor_position(Position::new(x, y)); } } @@ -246,15 +332,15 @@ enum Item<'a> { Line(Line<'static>), /// A row already rendered and cached on the message (010) — borrowed, not re-parsed. Cached(&'a Line<'static>), - /// An inline widget and the number of rows it occupies. - Widget(&'a RenderSpec, u16), + /// An inline widget, the number of rows it occupies, and its optional effect. + Widget(&'a RenderSpec, u16, Option<&'a EffectSpec>), } impl Item<'_> { fn height(&self) -> u16 { match self { Item::Line(_) | Item::Cached(_) => 1, - Item::Widget(_, h) => *h, + Item::Widget(_, h, _) => *h, } } } @@ -301,6 +387,7 @@ fn render_chat(app: &mut App, frame: &mut Frame<'_>, area: Rect) { let bottom = top.saturating_add(height); let mut y = 0u16; // absolute row index within the whole flow + let mut pending_effects: Vec<(EffectSpec, Rect)> = Vec::new(); for item in &items { let h = item.height(); let end = y.saturating_add(h); @@ -326,14 +413,30 @@ fn render_chat(app: &mut App, frame: &mut Frame<'_>, area: Rect) { ); } } - Item::Widget(spec, wh) => { + Item::Widget(spec, wh, effect) => { + let widget_rect = Rect::new(area.x, draw_y, width.min(area.width), avail); blit_widget(frame, spec, *wh, width, area, draw_y, skip, avail); + if let Some(fx) = effect { + pending_effects.push(((*fx).clone(), widget_rect)); + } } } } y = end; } + // One-shot effect drain: take() each widget's EffectSpec so it fires exactly once, + // not every frame. The rects were captured during the render pass above. + for msg in app.chat.iter_mut() { + if let Body::Widget(_, ref mut effect @ Some(_)) = msg.body { + effect.take(); + } + } + for (spec, rect) in pending_effects { + let ctx = super::effects::ResolveCtx::agent(app.visual, rect); + super::effects::apply(&mut app.effects, None, &spec, &ctx); + } + // Where the reader is in the flow. Drawn only when the conversation is taller than the pane — // a fully-visible conversation has no scroll position worth reporting. if let (Some(gutter), true) = (gutter, total > height) { @@ -428,20 +531,34 @@ fn chat_items(app: &App, width: u16) -> Vec> { } Body::Text(t) => { let style = line_style(msg.role); - let prefix = role_prefix(msg.role); - for (i, wl) in textwrap::wrap(t, wrap_w).into_iter().enumerate() { - let content = if i == 0 && !prefix.is_empty() { - format!("{prefix}{wl}") - } else { - wl.into_owned() + // The operator's own words get a gutter marker instead of a text prefix: `❯` in the + // accent on the first row, a matching indent on wrapped rows so the message reads as + // one block. Everything else is unmarked — tool lines already carry ▸/✓/✗ from the + // reducer, and marking every row marks nothing. + let user = msg.role == Role::User; + let body_w = if user { + wrap_w.saturating_sub(2) + } else { + wrap_w + }; + for (i, wl) in textwrap::wrap(t, body_w.max(4)).into_iter().enumerate() { + let line = match (user, i) { + (true, 0) => Line::from(vec![ + Span::styled("❯ ", role_style(ThemeRole::Accent)), + Span::styled(wl.into_owned(), style), + ]), + (true, _) => { + Line::from(vec![Span::raw(" "), Span::styled(wl.into_owned(), style)]) + } + _ => Line::styled(wl.into_owned(), style), }; - out.push(Item::Line(Line::styled(content, style))); + out.push(Item::Line(line)); } } - Body::Widget(spec) => { + Body::Widget(spec, effect) => { let h = crate::viz::buffer_render::spec_height(spec, width) .clamp(1, MAX_INLINE_WIDGET_ROWS); - out.push(Item::Widget(spec, h)); + out.push(Item::Widget(spec, h, effect.as_ref())); } } out.push(Item::Line(Line::raw(""))); // blank between messages @@ -453,7 +570,7 @@ fn chat_items(app: &App, width: u16) -> Vec> { /// titled with its id, stacked in insertion order and given an equal share of the column height. The /// panel's widget is drawn richly (truecolor) into the block's inner rect via `buffer_render`, which /// clips any overflow to the region (FR-012). -/// Smallest useful panel: top border + one content row + bottom border. +/// Smallest useful panel: its gutter label plus two content rows. const MIN_PANEL_ROWS: u16 = 3; /// How wide the panel column may be: 008's own sizing, narrowed by the visual level's cap (FR-011). @@ -522,42 +639,80 @@ fn render_panels(app: &mut App, frame: &mut Frame<'_>, area: Rect) { if app.panels.is_empty() || area.height == 0 || area.width == 0 { return; } - // Size each panel to its *content* (border + natural widget height) rather than splitting the + // Size each panel to its *content* (label + natural widget height) rather than splitting the // column evenly. An even split starved every panel once a few accumulated — 13 panels in 28 rows // left each with 3 rows showing a title and nothing else. Greedy top-down allocation keeps early // panels legible and reports the overflow honestly instead of silently squeezing everything. - let inner_w = area.width.saturating_sub(2).max(1); + // + // No box around the panel: nearly every widget draws its own titled border, so a panel border + // put two frames between the column edge and the data — the exact nesting the clutter audit + // caps at one. The panel's id becomes a one-row gutter label (`▍ id`) above the widget instead. + let inner_w = area.width.saturating_sub(1).max(1); let mut y = area.y; let mut shown = 0usize; // Where each panel landed, so the transition pass below can register against the real Rect // without re-deriving the greedy layout. let mut placed: Vec<(String, PanelArea)> = Vec::new(); - for (id, spec) in app.panels.iter() { + let focused = app.focus == Focus::Panels; + let sel = app.panel_sel.min(app.panels.len().saturating_sub(1)); + let entries: Vec<(String, bool)> = app + .panels + .iter_panels() + .map(|p| (p.id.clone(), p.collapsed)) + .collect(); + + for (i, (id, collapsed)) in entries.iter().enumerate() { + let spec = app.panels.get(id).expect("panel exists this frame"); let remaining = area.bottom().saturating_sub(y); + // A collapsed panel is exactly its label row; an expanded one needs label + content. + let need = if *collapsed { 1 } else { MIN_PANEL_ROWS }; // Keep a row free for the "+N more" note if this isn't the last panel and space is tight. - let more_after = app.panels.len() - shown > 1; - let reserve = u16::from(more_after && remaining <= MIN_PANEL_ROWS + 1); - if remaining.saturating_sub(reserve) < MIN_PANEL_ROWS { + let more_after = entries.len() - shown > 1; + let reserve = u16::from(more_after && remaining <= need + 1); + if remaining.saturating_sub(reserve) < need { break; } - let desired = crate::viz::buffer_render::spec_height(spec, inner_w).saturating_add(2); - let h = desired.clamp(MIN_PANEL_ROWS, remaining - reserve); + let h = if *collapsed { + 1 + } else { + let desired = crate::viz::buffer_render::spec_height(spec, inner_w).saturating_add(1); + desired.clamp(MIN_PANEL_ROWS, remaining - reserve) + }; let rect = Rect::new(area.x, y, area.width, h); - let block = Block::default() - .borders(Borders::ALL) - .border_style(dim_style()) - .title(Span::styled( - format!(" {id} "), - role_style(ThemeRole::Accent), - )); - let inner = block.inner(rect); - frame.render_widget(block, rect); - if inner.width > 0 && inner.height > 0 { - crate::viz::buffer_render::render_into(spec, inner, frame.buffer_mut()); + // The comb cell is bee's bullet: honey marker, accent id — the same two-tone the header + // badge establishes. Filled comb = expanded, hollow = collapsed; reverse video marks the + // selection while the column has focus (the one universally-supported selection signal). + let marker = if *collapsed { "⬡" } else { "⬢" }; + let selected = focused && i == sel; + let rv = |s: Style| { + if selected { + s.add_modifier(ratatui::style::Modifier::REVERSED) + } else { + s + } + }; + let mut label = vec![ + Span::styled(marker, rv(role_style(ThemeRole::Info))), + Span::styled(format!(" {id}"), rv(role_style_bold(ThemeRole::Accent))), + ]; + if *collapsed { + label.push(Span::styled(" ⋯", rv(dim_style()))); + } + frame.render_widget( + Paragraph::new(Line::from(label)), + Rect::new(rect.x, rect.y, rect.width, 1), + ); + if !collapsed { + let inner = Rect::new(rect.x + 1, rect.y + 1, inner_w, h - 1); + if inner.width > 0 && inner.height > 0 { + crate::viz::buffer_render::render_into(spec, inner, frame.buffer_mut()); + } + // Collapsed panels register no transition and snapshot nothing — there is no content + // region for an effect to play over. + placed.push((id.to_string(), PanelArea { outer: rect, inner })); } - placed.push((id.to_string(), PanelArea { outer: rect, inner })); y += h; shown += 1; } @@ -589,20 +744,15 @@ fn render_panels(app: &mut App, frame: &mut Frame<'_>, area: Rect) { fn line_style(role: Role) -> Style { match role { - Role::User => role_style_bold(ThemeRole::Accent), + // Weight distinguishes the operator's words; the accent lives in the `❯` gutter marker so + // a long user message doesn't become a wall of accent color. + Role::User => role_style_bold(ThemeRole::Text), Role::Assistant => role_style(ThemeRole::Text), Role::System => dim_style(), Role::Tool => dim_style(), } } -fn role_prefix(role: Role) -> &'static str { - match role { - Role::User => "you ", - _ => "", - } -} - fn too_small(frame: &mut Frame<'_>, area: Rect) { let msg = Paragraph::new("terminal too small (min 40×10)") .alignment(Alignment::Center) @@ -611,6 +761,7 @@ fn too_small(frame: &mut Frame<'_>, area: Rect) { } fn render_help(frame: &mut Frame<'_>, area: Rect) { + let key_col = 13usize; let keys = [ ("Enter", "send message (Shift+Enter: newline)"), ( @@ -620,32 +771,40 @@ fn render_help(frame: &mut Frame<'_>, area: Rect) { ("Ctrl-P/N", "previous / next input history"), ("PgUp PgDn", "page the chat"), ("gg G", "top / bottom"), - ("Tab", "cycle focus (input · chat)"), + ("Tab", "cycle focus (input · chat · panels)"), ("p", "toggle panel overlay (narrow layouts)"), + ("j k Space x", "panels focus: select · collapse · close"), ("y", "yank newest message (OSC 52)"), ("? Esc", "close this help"), ("q Ctrl-C", "quit"), ("Ctrl-Z", "suspend (fg to resume)"), ]; - let mut lines = vec![Line::styled( - "keybindings", - role_style_bold(ThemeRole::Info), - )]; - lines.push(Line::raw("")); + // No inner heading: the box's " help " title already names it, and two labels for one popup is + // exactly the duplicate-signal clutter the design doc tells us to cut. + let mut lines = Vec::new(); for (k, desc) in keys { lines.push(Line::from(vec![ - Span::styled(format!(" {k:<11}"), role_style(ThemeRole::Accent)), + Span::styled(format!(" {k: