From a5225c6d2e40f8a4cf72461ddd0ed4aa54357cec Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:04:26 +0000 Subject: [PATCH 01/97] feat: add `hs stream` command for live WebSocket entity streaming (Phase 1) Core streaming MVP: connect to a deployed stack's WebSocket, subscribe to a view (e.g. OreRound/latest), and stream entity data as NDJSON to stdout. Supports --raw mode for raw frames and merged entity output (default). Resolves WebSocket URL from --url, --stack, or hyperstack.toml. Also exports parse_frame, parse_snapshot_entities, ClientMessage, and deep_merge_with_append from hyperstack-sdk for reuse. --- Cargo.lock | 4 + PLAN-hs-stream.md | 312 ++++++++++++++++++++++++++++++ cli/Cargo.toml | 4 + cli/src/commands/mod.rs | 1 + cli/src/commands/stream/client.rs | 156 +++++++++++++++ cli/src/commands/stream/mod.rs | 134 +++++++++++++ cli/src/commands/stream/output.rs | 48 +++++ cli/src/main.rs | 5 + rust/hyperstack-sdk/src/lib.rs | 6 +- rust/hyperstack-sdk/src/store.rs | 2 +- 10 files changed, 668 insertions(+), 4 deletions(-) create mode 100644 PLAN-hs-stream.md create mode 100644 cli/src/commands/stream/client.rs create mode 100644 cli/src/commands/stream/mod.rs create mode 100644 cli/src/commands/stream/output.rs diff --git a/Cargo.lock b/Cargo.lock index 8c9afc8f..dad7e9d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1663,8 +1663,10 @@ dependencies = [ "dialoguer", "dirs", "flate2", + "futures-util", "hyperstack-idl", "hyperstack-interpreter", + "hyperstack-sdk", "indicatif", "regex", "reqwest 0.11.27", @@ -1672,6 +1674,8 @@ dependencies = [ "serde", "serde_json", "tar", + "tokio", + "tokio-tungstenite 0.21.0", "toml 0.8.23", "uuid", ] diff --git a/PLAN-hs-stream.md b/PLAN-hs-stream.md new file mode 100644 index 00000000..a329dde9 --- /dev/null +++ b/PLAN-hs-stream.md @@ -0,0 +1,312 @@ +# Plan: `hs stream` — Live WebSocket Stream CLI + TUI + +## Context + +Users can deploy stacks with `hs up` but have no CLI way to observe live stream data without writing code. This is the highest-leverage missing UX feature. The goal is a single `hs stream` command that: + +1. Connects to a deployed stack's WebSocket and streams entity data to stdout (pipe-friendly NDJSON) +2. Supports rich filtering, `--first` triggers, raw vs merged output, NO_DNA agent format +3. Provides an interactive TUI for exploring entities, time-traveling through updates +4. Ensures every TUI action has a non-interactive CLI equivalent for agent consumption +5. Supports saving/loading snapshot recordings + +## Key Existing Infrastructure + +| Component | Location | Reuse | +|-----------|----------|-------| +| CLI entry point | [main.rs](cli/src/main.rs) | Add `Stream` variant to `Commands` enum | +| Config with WS URLs | [config.rs](cli/src/config.rs) `StackConfig.url` | Resolve WebSocket URL from `hyperstack.toml` | +| SDK Frame types | [frame.rs](rust/hyperstack-sdk/src/frame.rs) | `Frame`, `parse_frame`, `parse_snapshot_entities`, gzip handling | +| SDK Subscription | [subscription.rs](rust/hyperstack-sdk/src/subscription.rs) | `ClientMessage`, `Subscription` types | +| SDK Connection | [connection.rs](rust/hyperstack-sdk/src/connection.rs) | Pattern reference for WS connect + reconnect loop | +| SDK Store + merge | [store.rs](rust/hyperstack-sdk/src/store.rs) | `deep_merge_with_append` (currently private — needs `pub`) | +| SDK SharedStore | [store.rs](rust/hyperstack-sdk/src/store.rs) | `StoreUpdate` type with `previous`/`patch` fields | + +## Command Design + +``` +hs stream [OPTIONS] + +ARGS: + Entity/view to subscribe: EntityName/mode (e.g. OreRound/latest) + +CONNECTION: + --url WebSocket URL override + --stack Stack name (resolves URL from hyperstack.toml) + --key Entity key for state-mode subscriptions + +OUTPUT MODE (mutually exclusive): + --raw Raw WebSocket frames (no merge) + --no-dna NO_DNA agent-friendly envelope format + [default] Merged entity NDJSON + +FILTERING: + --where Filter: field=value, field>N, field~regex (repeatable, ANDed) + --select Project specific fields (comma-separated dot paths) + --first Exit after first entity matches filter + --ops Filter by operation type: upsert,patch,delete + +SUBSCRIPTION: + --take Max entities in snapshot + --skip Skip N entities + --no-snapshot Skip initial snapshot + --after Resume from cursor (seq value) + +RECORDING: + --save Record frames to JSON file + --duration Auto-stop recording after N seconds + --load Replay a saved recording (no WS connection) + +TUI: + --tui Interactive terminal UI + +HISTORY (non-interactive agent equivalents): + --history Show update history for --key entity + --at Show entity at history index (0=latest) + --diff Show diff between consecutive updates + --count Show running count of updates only +``` + +### URL Resolution Priority +1. `--url wss://...` — explicit +2. `--stack my-stack` — lookup `StackConfig.url` via `config.find_stack()` +3. Auto-match entity name from view to a stack in config +4. Error with list of available stacks + +### NO_DNA Format (`--no-dna`) +When `NO_DNA` env var is set OR `--no-dna` flag is used: +- Each line is a JSON envelope: `{"schema":"no-dna/v1", "tool":"hs-stream", "action":"entity_update"|"connected"|"snapshot_complete"|"error", "data":{...}, "meta":{update_count, entities_tracked, connected}}` +- No spinners, no color, no interactive prompts +- Lifecycle events (connected, snapshot_complete, disconnected) emitted as structured events + +### Filter DSL (`--where`) +``` +field=value exact match (string or number auto-coercion) +field!=value not equal +field>N greater than (numeric) +field>=N greater or equal +field` for patch merging + +**Store path** (`--tui`, `--history`, `--save`): +``` +connect_async(url) → subscribe → frame loop → EntityStore → [filter] → output/TUI +``` +- `EntityStore` tracks full entity state + history ring buffer per entity +- History capped at configurable max (default 1000 entries per entity) + +### EntityStore (new, in CLI) +```rust +struct EntityStore { + entities: HashMap, + max_history: usize, +} +struct EntityRecord { + current: Value, + history: VecDeque, // ring buffer +} +struct HistoryEntry { + timestamp: DateTime, + seq: Option, + op: Operation, + state: Value, // full entity after this update + patch: Option, // raw patch for patch ops +} +``` + +### TUI Architecture +Uses `ratatui` + `crossterm` (behind `tui` feature flag). + +**Layout:** +``` +┌──────────────────────────────────────────────────────┐ +│ hs stream OreRound/latest [connected] │ +├─────────────────┬────────────────────────────────────┤ +│ Entities │ Entity Detail │ +│ │ │ +│ > round_42 │ { │ +│ round_43 │ "roundId": 42, │ +│ round_44 │ "rewards": "1.5 SOL", │ +│ │ ... │ +│ │ } │ +├─────────────────┴────────────────────────────────────┤ +│ History: [|<] [<] update 3/7 [>] [>|] │ +├──────────────────────────────────────────────────────┤ +│ Filter: --where roundId>40 Updates: 127 │ +└──────────────────────────────────────────────────────┘ +``` + +**Key bindings:** +- `j/k`/arrows: navigate entity list +- `Enter`: focus detail (full width), `Esc`: back +- `h/l`/left/right: step through history (time travel) +- `Home/End`: oldest/newest history +- `d`: toggle diff view +- `/`: type filter expression +- `r`: toggle raw/merged +- `s`: save snapshot +- `p`: pause/resume +- `q`: quit + +**Event loop:** `tokio::select!` over crossterm events (16ms tick) + WS frame channel (`mpsc`). + +### TUI ↔ Agent Equivalence Table + +| TUI Action | Agent CLI | +|---|---| +| Browse entity list | `hs stream View/list` (prints all entities) | +| Select entity by key | `hs stream View/mode --key ` | +| View detail | Default merged output | +| Time travel to step N | `--history --at N --key ` | +| Show diff | `--diff --key ` | +| Filter | `--where "field=value"` | +| Raw frames | `--raw` | +| Save dataset | `--save file.json --duration 30` | +| Load replay | `--load file.json` | +| Count updates | `--count` | +| First match | `--first --where "field=value"` | + +### Snapshot File Format +```json +{ + "version": 1, + "view": "OreRound/latest", + "url": "wss://...", + "captured_at": "2026-03-23T10:00:00Z", + "duration_ms": 30000, + "frame_count": 147, + "frames": [ + {"ts": 1711180800000, "frame": {"mode": "list", "entity": "OreRound/latest", "op": "upsert", "...": "..."}}, + "..." + ] +} +``` +- `--load file.json` replays through the same merge/filter/output pipeline +- `--load file.json --tui` enables TUI replay with time travel + +## File Structure + +``` +cli/src/ + main.rs # Add Stream to Commands enum + commands/ + mod.rs # Add pub mod stream + stream/ + mod.rs # Entry point, URL resolution, tokio runtime, dispatch + client.rs # WebSocket connect, subscribe, frame loop (reuses SDK types) + store.rs # EntityStore with history + deep_merge_with_append + filter.rs # --where DSL parser and evaluator + output.rs # Formatters: ndjson, no_dna, raw + snapshot.rs # --save/--load file I/O and replay + tui/ + mod.rs # TuiApp state + main event loop + ui.rs # ratatui layout rendering + widgets.rs # Entity list, JSON viewer, timeline bar +``` + +## Dependencies to Add (cli/Cargo.toml) + +```toml +# Async (only used by stream command) +tokio = { version = "1.0", features = ["rt-multi-thread", "sync", "time", "macros", "signal"] } +futures-util = { version = "0.3", features = ["sink"] } +tokio-tungstenite = { version = "0.21", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } + +# Reuse SDK frame/subscription types +hyperstack-sdk = { path = "../rust/hyperstack-sdk", version = "0.5.10" } + +# TUI (behind feature flag) +ratatui = { version = "0.29", optional = true } +crossterm = { version = "0.28", optional = true } + +[features] +tui = ["ratatui", "crossterm"] +``` + +### SDK Change Required +Make `deep_merge_with_append` public in [store.rs](rust/hyperstack-sdk/src/store.rs:119): +```rust +pub fn deep_merge_with_append(...) // was fn (private) +``` + +## Implementation Phases + +### Phase 1: Core Streaming (MVP) +1. Add `Stream` variant to `Commands` enum in `main.rs` +2. Create `commands/stream/mod.rs` — arg parsing, URL resolution, tokio runtime entry +3. Implement `client.rs` — direct WS connection using `tokio-tungstenite`, subscribe, frame receive loop +4. Implement `output.rs` — NDJSON line output to stdout +5. Wire `--raw` mode (frame → JSON line → stdout) +6. Wire merged mode with inline `HashMap` + `deep_merge_with_append` +7. Make `deep_merge_with_append` pub in SDK + +### Phase 2: Filtering + Flags +8. Implement `filter.rs` — parse `--where` expressions, evaluate against `serde_json::Value` +9. Wire `--first` (disconnect + exit 0 after first filter match) +10. Wire `--select` (project fields from output) +11. Wire `--ops` (filter by operation type) +12. Implement `--no-dna` output envelope format +13. Wire `--count` mode + +### Phase 3: Recording +14. Implement `snapshot.rs` — `--save` appends timestamped frames to buffer, writes on Ctrl+C or `--duration` +15. Implement `--load` replay through same pipeline + +### Phase 4: History + Store +16. Implement `store.rs` — `EntityStore` with history ring buffer +17. Wire `--history`, `--at`, `--diff` flags for non-interactive history access + +### Phase 5: TUI +18. Add `ratatui`/`crossterm` behind `tui` feature flag +19. Implement `tui/mod.rs` — `TuiApp` state, `select!` event loop +20. Implement `tui/ui.rs` — three-panel layout +21. Implement `tui/widgets.rs` — entity list, JSON viewer with syntax highlighting, timeline bar +22. Wire entity navigation, detail view, history stepping, diff view, filter input, pause/resume, save + +## Verification + +### Unit Tests +- `filter.rs`: All operators, nested paths, type coercion, null handling +- `store.rs`: Merge behavior, history ring buffer, eviction +- `output.rs`: NDJSON format, NO_DNA envelope, field projection +- `snapshot.rs`: Save/load round-trip, replay ordering + +### Integration Tests +- Mock WS server (tokio-tungstenite server in test) sends scripted frame sequence +- Verify `--raw` outputs valid NDJSON +- Verify merged mode correctly patches entities +- Verify `--where` excludes non-matching +- Verify `--first` exits after match +- Verify `--save`/`--load` round-trip + +### Manual E2E +- `hs stream OreRound/latest --stack ore` against a live deployment +- Pipe to `jq '.rewards'` — verify valid JSON per line +- `hs stream OreRound/latest --raw | head -5` — verify `head` causes clean exit +- `hs stream OreRound/latest --tui` — interactive exploration +- `hs stream OreRound/latest --save test.json --duration 10 && hs stream --load test.json --tui` diff --git a/cli/Cargo.toml b/cli/Cargo.toml index b30b810b..4a8aba45 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -33,6 +33,7 @@ indicatif = "0.17" console = "0.15" hyperstack-interpreter = { version = "0.5.10", path = "../interpreter" } hyperstack-idl = { path = "../hyperstack-idl", version = "0.1.5" } +hyperstack-sdk = { path = "../rust/hyperstack-sdk", version = "0.5.10" } reqwest = { version = "0.11", default-features = false, features = ["json", "blocking", "rustls-tls"] } dirs = "5.0" rpassword = "7.3" @@ -41,4 +42,7 @@ flate2 = "1.0" tar = "0.4" uuid = { version = "1.0", features = ["v4"] } regex = "1.10" +tokio = { version = "1.0", features = ["rt-multi-thread", "sync", "time", "macros", "signal"] } +futures-util = { version = "0.3", features = ["sink"] } +tokio-tungstenite = { version = "0.21", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index b855471f..5ade0524 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -7,5 +7,6 @@ pub mod idl; pub mod sdk; pub mod stack; pub mod status; +pub mod stream; pub mod telemetry; pub mod up; diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs new file mode 100644 index 00000000..0c6659fd --- /dev/null +++ b/cli/src/commands/stream/client.rs @@ -0,0 +1,156 @@ +use anyhow::{Context, Result}; +use futures_util::{SinkExt, StreamExt}; +use hyperstack_sdk::{ + deep_merge_with_append, parse_frame, parse_snapshot_entities, ClientMessage, Frame, Operation, + Subscription, +}; +use std::collections::HashMap; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +use super::output; +use super::StreamArgs; + +pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { + let (ws, _) = connect_async(&url) + .await + .with_context(|| format!("Failed to connect to {}", url))?; + + eprintln!("Connected."); + + let (mut ws_tx, mut ws_rx) = ws.split(); + + // Build subscription + let mut sub = Subscription::new(view); + if let Some(key) = &args.key { + sub = sub.with_key(key.clone()); + } + if let Some(take) = args.take { + sub = sub.with_take(take); + } + if let Some(skip) = args.skip { + sub = sub.with_skip(skip); + } + if args.no_snapshot { + sub = sub.with_snapshot(false); + } + if let Some(after) = &args.after { + sub = sub.after(after.clone()); + } + + // Send subscribe message + let msg = serde_json::to_string(&ClientMessage::Subscribe(sub)) + .context("Failed to serialize subscribe message")?; + ws_tx + .send(Message::Text(msg)) + .await + .context("Failed to send subscribe message")?; + + // Entity state for merged mode + let mut entities: HashMap = HashMap::new(); + + // Ping interval + let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(30)); + + // Handle Ctrl+C + let shutdown = tokio::signal::ctrl_c(); + tokio::pin!(shutdown); + + loop { + tokio::select! { + msg = ws_rx.next() => { + match msg { + Some(Ok(Message::Binary(bytes))) => { + match parse_frame(&bytes) { + Ok(frame) => process_frame(frame, args, &mut entities)?, + Err(e) => eprintln!("Warning: failed to parse frame: {}", e), + } + } + Some(Ok(Message::Text(text))) => { + // Check for subscribed frame + if let Ok(value) = serde_json::from_str::(&text) { + if value.get("op").and_then(|v| v.as_str()) == Some("subscribed") { + eprintln!("Subscribed to {}", view); + continue; + } + } + match serde_json::from_str::(&text) { + Ok(frame) => process_frame(frame, args, &mut entities)?, + Err(e) => eprintln!("Warning: failed to parse text frame: {}", e), + } + } + Some(Ok(Message::Ping(payload))) => { + let _ = ws_tx.send(Message::Pong(payload)).await; + } + Some(Ok(Message::Close(_))) => { + eprintln!("Connection closed by server."); + break; + } + Some(Err(e)) => { + eprintln!("WebSocket error: {}", e); + break; + } + None => { + eprintln!("Connection closed."); + break; + } + _ => {} + } + } + _ = ping_interval.tick() => { + if let Ok(msg) = serde_json::to_string(&ClientMessage::Ping) { + let _ = ws_tx.send(Message::Text(msg)).await; + } + } + _ = &mut shutdown => { + eprintln!("\nDisconnecting..."); + let _ = ws_tx.close().await; + break; + } + } + } + + Ok(()) +} + +fn process_frame( + frame: Frame, + args: &StreamArgs, + entities: &mut HashMap, +) -> Result<()> { + if args.raw { + return output::print_raw_frame(&frame); + } + + let op = frame.operation(); + + match op { + Operation::Snapshot => { + let snapshot_entities = parse_snapshot_entities(&frame.data); + for entity in snapshot_entities { + entities.insert(entity.key.clone(), entity.data.clone()); + output::print_entity_update(&frame.entity, &entity.key, "snapshot", &entity.data)?; + } + } + Operation::Upsert | Operation::Create => { + entities.insert(frame.key.clone(), frame.data.clone()); + output::print_entity_update(&frame.entity, &frame.key, &frame.op, &frame.data)?; + } + Operation::Patch => { + let entry = entities + .entry(frame.key.clone()) + .or_insert_with(|| serde_json::json!({})); + deep_merge_with_append(entry, &frame.data, &frame.append, ""); + let merged = entry.clone(); + output::print_entity_update(&frame.entity, &frame.key, "patch", &merged)?; + } + Operation::Delete => { + entities.remove(&frame.key); + output::print_delete(&frame.entity, &frame.key)?; + } + Operation::Subscribed => { + // Handled in the text message branch + } + } + + Ok(()) +} diff --git a/cli/src/commands/stream/mod.rs b/cli/src/commands/stream/mod.rs new file mode 100644 index 00000000..a35be68e --- /dev/null +++ b/cli/src/commands/stream/mod.rs @@ -0,0 +1,134 @@ +mod client; +mod output; + +use anyhow::{bail, Context, Result}; +use clap::Args; + +use crate::config::HyperstackConfig; + +#[derive(Args)] +pub struct StreamArgs { + /// View to subscribe to: EntityName/mode (e.g. OreRound/latest) + pub view: Option, + + /// Entity key to watch (for state-mode subscriptions) + #[arg(short, long)] + pub key: Option, + + /// WebSocket URL override + #[arg(long)] + pub url: Option, + + /// Stack name (resolves URL from hyperstack.toml) + #[arg(short, long)] + pub stack: Option, + + /// Output raw WebSocket frames instead of merged entities + #[arg(long)] + pub raw: bool, + + /// Max entities in snapshot + #[arg(long)] + pub take: Option, + + /// Skip N entities in snapshot + #[arg(long)] + pub skip: Option, + + /// Disable initial snapshot + #[arg(long)] + pub no_snapshot: bool, + + /// Resume from cursor (seq value) + #[arg(long)] + pub after: Option, +} + +pub fn run(args: StreamArgs, config_path: &str) -> Result<()> { + let view = args.view.as_deref().unwrap_or_else(|| { + eprintln!("Error: argument is required (e.g. OreRound/latest)"); + std::process::exit(1); + }); + + let url = resolve_url(&args, config_path, view)?; + + eprintln!("Connecting to {} ...", url); + eprintln!("Subscribing to {} ...", view); + + let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?; + rt.block_on(client::stream(url, view, &args)) +} + +fn resolve_url(args: &StreamArgs, config_path: &str, view: &str) -> Result { + // 1. Explicit --url + if let Some(url) = &args.url { + return Ok(url.clone()); + } + + let config = HyperstackConfig::load_optional(config_path)?; + + // 2. Explicit --stack name + if let Some(stack_name) = &args.stack { + if let Some(config) = &config { + if let Some(stack) = config.find_stack(stack_name) { + if let Some(url) = &stack.url { + return Ok(url.clone()); + } + bail!( + "Stack '{}' found in config but has no url set.\n\ + Set it in hyperstack.toml or use --url to specify the WebSocket URL.", + stack_name + ); + } + } + bail!( + "Stack '{}' not found in {}.\n\ + Available stacks: {}", + stack_name, + config_path, + list_stacks(config.as_ref()), + ); + } + + // 3. Auto-match entity name from view + let entity_name = view.split('/').next().unwrap_or(view); + if let Some(config) = &config { + if let Some(stack) = config.find_stack(entity_name) { + if let Some(url) = &stack.url { + return Ok(url.clone()); + } + } + // Try matching against all stacks + for stack in &config.stacks { + if let Some(url) = &stack.url { + return Ok(url.clone()); + } + } + } + + bail!( + "Could not determine WebSocket URL.\n\n\ + Specify one of:\n \ + --url wss://your-stack.stack.usehyperstack.com\n \ + --stack (resolves from hyperstack.toml)\n\n\ + Available stacks: {}", + list_stacks(config.as_ref()), + ) +} + +fn list_stacks(config: Option<&HyperstackConfig>) -> String { + match config { + Some(config) if !config.stacks.is_empty() => config + .stacks + .iter() + .map(|s| { + s.name + .as_deref() + .unwrap_or(&s.stack) + .to_string() + }) + .collect::>() + .join(", "), + _ => "(none — create hyperstack.toml with [[stacks]] entries)".to_string(), + } +} diff --git a/cli/src/commands/stream/output.rs b/cli/src/commands/stream/output.rs new file mode 100644 index 00000000..6eab2dde --- /dev/null +++ b/cli/src/commands/stream/output.rs @@ -0,0 +1,48 @@ +use anyhow::Result; +use hyperstack_sdk::Frame; +use std::io::{self, Write}; + +/// Print a raw WebSocket frame as a single JSON line to stdout. +pub fn print_raw_frame(frame: &Frame) -> Result<()> { + let line = serde_json::to_string(frame)?; + let stdout = io::stdout(); + let mut out = stdout.lock(); + writeln!(out, "{}", line)?; + Ok(()) +} + +/// Print a merged entity update as a single JSON line to stdout. +/// Output format: {"view": "...", "key": "...", "op": "...", "data": {...}} +pub fn print_entity_update( + view: &str, + key: &str, + op: &str, + data: &serde_json::Value, +) -> Result<()> { + let output = serde_json::json!({ + "view": view, + "key": key, + "op": op, + "data": data, + }); + let line = serde_json::to_string(&output)?; + let stdout = io::stdout(); + let mut out = stdout.lock(); + writeln!(out, "{}", line)?; + Ok(()) +} + +/// Print an entity deletion as a single JSON line to stdout. +pub fn print_delete(view: &str, key: &str) -> Result<()> { + let output = serde_json::json!({ + "view": view, + "key": key, + "op": "delete", + "data": null, + }); + let line = serde_json::to_string(&output)?; + let stdout = io::stdout(); + let mut out = stdout.lock(); + writeln!(out, "{}", line)?; + Ok(()) +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 28594bf4..b8e6ceb0 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -146,6 +146,9 @@ enum Commands { /// Inspect and analyze Anchor/Shank IDL files Idl(commands::idl::IdlArgs), + + /// Stream live entity data from a deployed stack via WebSocket + Stream(commands::stream::StreamArgs), } #[derive(Subcommand)] @@ -418,6 +421,7 @@ fn command_name(cmd: &Commands) -> &'static str { Commands::Build(_) => "build", Commands::Telemetry(_) => "telemetry", Commands::Idl(_) => "idl", + Commands::Stream(_) => "stream", } } @@ -540,6 +544,7 @@ fn run(cli: Cli) -> anyhow::Result<()> { } => commands::build::status(build_id, watch, json || cli.json), }, Commands::Idl(args) => commands::idl::run(args), + Commands::Stream(args) => commands::stream::run(args, &cli.config), Commands::Telemetry(telemetry_cmd) => match telemetry_cmd { TelemetryCommands::Status => commands::telemetry::status(), TelemetryCommands::Enable => commands::telemetry::enable(), diff --git a/rust/hyperstack-sdk/src/lib.rs b/rust/hyperstack-sdk/src/lib.rs index c44ecafd..28cb2d15 100644 --- a/rust/hyperstack-sdk/src/lib.rs +++ b/rust/hyperstack-sdk/src/lib.rs @@ -33,11 +33,11 @@ pub use client::{HyperStack, HyperStackBuilder}; pub use connection::ConnectionState; pub use entity::Stack; pub use error::HyperStackError; -pub use frame::{Frame, Mode, Operation}; -pub use store::{SharedStore, StoreUpdate}; +pub use frame::{parse_frame, parse_snapshot_entities, Frame, Mode, Operation, SnapshotEntity}; +pub use store::{deep_merge_with_append, SharedStore, StoreUpdate}; pub use stream::{ EntityStream, FilterMapStream, FilteredStream, KeyFilter, MapStream, RichEntityStream, RichUpdate, Update, UseStream, }; -pub use subscription::Subscription; +pub use subscription::{ClientMessage, Subscription}; pub use view::{RichWatchBuilder, StateView, UseBuilder, ViewBuilder, ViewHandle, Views, WatchBuilder}; diff --git a/rust/hyperstack-sdk/src/store.rs b/rust/hyperstack-sdk/src/store.rs index 91afe189..2bd690d7 100644 --- a/rust/hyperstack-sdk/src/store.rs +++ b/rust/hyperstack-sdk/src/store.rs @@ -116,7 +116,7 @@ struct ViewData { sorted_keys: BTreeMap, } -fn deep_merge_with_append( +pub fn deep_merge_with_append( target: &mut Value, patch: &Value, append_paths: &[String], From 7c3256da51f83afbeb33f0aa4b6eeb3ad9a50676 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:12:54 +0000 Subject: [PATCH 02/97] feat: add filtering, --first, --select, --ops, --no-dna, --count to hs stream (Phase 2) - Filter DSL via --where: =, !=, >, >=, <, <=, ~regex, !~regex, ?, !? with dot-path support for nested fields (e.g. --where "user.age>18") - --first exits after first entity matches filter criteria - --select projects specific fields (comma-separated dot paths) - --ops filters by operation type (upsert, patch, delete) - --no-dna outputs NO_DNA v1 agent-friendly envelope format with lifecycle events (connected, snapshot_complete, entity_update, disconnected) - --count shows running update count on stderr - 9 unit tests for filter parsing and evaluation --- cli/src/commands/stream/client.rs | 198 ++++++++++++++++--- cli/src/commands/stream/filter.rs | 313 ++++++++++++++++++++++++++++++ cli/src/commands/stream/mod.rs | 25 +++ cli/src/commands/stream/output.rs | 42 +++- 4 files changed, 553 insertions(+), 25 deletions(-) create mode 100644 cli/src/commands/stream/filter.rs diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 0c6659fd..700ad8bd 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -4,12 +4,25 @@ use hyperstack_sdk::{ deep_merge_with_append, parse_frame, parse_snapshot_entities, ClientMessage, Frame, Operation, Subscription, }; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use tokio_tungstenite::{connect_async, tungstenite::Message}; -use super::output; +use super::filter::{self, Filter}; +use super::output::{self, OutputMode}; use super::StreamArgs; +struct StreamState { + entities: HashMap, + filter: Filter, + select_fields: Option>>, + allowed_ops: Option>, + output_mode: OutputMode, + first: bool, + count_only: bool, + update_count: u64, + entity_count: u64, +} + pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { let (ws, _) = connect_async(&url) .await @@ -45,8 +58,35 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { .await .context("Failed to send subscribe message")?; - // Entity state for merged mode - let mut entities: HashMap = HashMap::new(); + // Parse filters + let filter = Filter::parse(&args.filters)?; + let select_fields = args.select.as_deref().map(filter::parse_select); + let allowed_ops = args.ops.as_deref().map(|ops| { + ops.split(',') + .map(|s| s.trim().to_lowercase()) + .collect::>() + }); + + let output_mode = if args.raw { + OutputMode::Raw + } else if args.no_dna { + output::emit_no_dna_event("connected", view, &serde_json::json!({"url": url}), 0, 0)?; + OutputMode::NoDna + } else { + OutputMode::Merged + }; + + let mut state = StreamState { + entities: HashMap::new(), + filter, + select_fields, + allowed_ops, + output_mode, + first: args.first, + count_only: args.count, + update_count: 0, + entity_count: 0, + }; // Ping interval let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(30)); @@ -55,18 +95,23 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { let shutdown = tokio::signal::ctrl_c(); tokio::pin!(shutdown); + let mut snapshot_complete = false; + loop { tokio::select! { msg = ws_rx.next() => { match msg { Some(Ok(Message::Binary(bytes))) => { match parse_frame(&bytes) { - Ok(frame) => process_frame(frame, args, &mut entities)?, + Ok(frame) => { + if process_frame(frame, view, &mut state)? { + return Ok(()); + } + } Err(e) => eprintln!("Warning: failed to parse frame: {}", e), } } Some(Ok(Message::Text(text))) => { - // Check for subscribed frame if let Ok(value) = serde_json::from_str::(&text) { if value.get("op").and_then(|v| v.as_str()) == Some("subscribed") { eprintln!("Subscribed to {}", view); @@ -74,7 +119,23 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { } } match serde_json::from_str::(&text) { - Ok(frame) => process_frame(frame, args, &mut entities)?, + Ok(frame) => { + let was_snapshot = frame.is_snapshot(); + if process_frame(frame, view, &mut state)? { + return Ok(()); + } + // Detect snapshot completion (first non-snapshot after snapshots) + if !was_snapshot && !snapshot_complete && state.update_count > 0 { + snapshot_complete = true; + if let OutputMode::NoDna = state.output_mode { + output::emit_no_dna_event( + "snapshot_complete", view, + &serde_json::json!({"entity_count": state.entity_count}), + state.update_count, state.entity_count, + )?; + } + } + } Err(e) => eprintln!("Warning: failed to parse text frame: {}", e), } } @@ -109,48 +170,137 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { } } + if let OutputMode::NoDna = state.output_mode { + output::emit_no_dna_event( + "disconnected", view, + &serde_json::json!(null), + state.update_count, state.entity_count, + )?; + } + Ok(()) } +/// Process a frame. Returns true if the stream should end (--first matched). fn process_frame( frame: Frame, - args: &StreamArgs, - entities: &mut HashMap, -) -> Result<()> { - if args.raw { - return output::print_raw_frame(&frame); + view: &str, + state: &mut StreamState, +) -> Result { + let op = frame.operation(); + let op_str = &frame.op; + + // Filter by operation type + if let Some(allowed) = &state.allowed_ops { + if op != Operation::Snapshot && !allowed.contains(op_str.as_str()) { + return Ok(false); + } } - let op = frame.operation(); + if let OutputMode::Raw = state.output_mode { + // In raw mode, apply filter to the frame data directly + if !state.filter.is_empty() && !state.filter.matches(&frame.data) { + return Ok(false); + } + state.update_count += 1; + if state.count_only { + output::print_count(state.update_count)?; + } else { + output::print_raw_frame(&frame)?; + } + return Ok(state.first && !state.filter.is_empty()); + } match op { Operation::Snapshot => { let snapshot_entities = parse_snapshot_entities(&frame.data); for entity in snapshot_entities { - entities.insert(entity.key.clone(), entity.data.clone()); - output::print_entity_update(&frame.entity, &entity.key, "snapshot", &entity.data)?; + state.entities.insert(entity.key.clone(), entity.data.clone()); + state.entity_count = state.entities.len() as u64; + if emit_entity(state, view, &entity.key, "snapshot", &entity.data)? { + return Ok(true); + } } } Operation::Upsert | Operation::Create => { - entities.insert(frame.key.clone(), frame.data.clone()); - output::print_entity_update(&frame.entity, &frame.key, &frame.op, &frame.data)?; + state.entities.insert(frame.key.clone(), frame.data.clone()); + state.entity_count = state.entities.len() as u64; + if emit_entity(state, view, &frame.key, op_str, &frame.data)? { + return Ok(true); + } } Operation::Patch => { - let entry = entities + let entry = state.entities .entry(frame.key.clone()) .or_insert_with(|| serde_json::json!({})); deep_merge_with_append(entry, &frame.data, &frame.append, ""); let merged = entry.clone(); - output::print_entity_update(&frame.entity, &frame.key, "patch", &merged)?; + state.entity_count = state.entities.len() as u64; + if emit_entity(state, view, &frame.key, "patch", &merged)? { + return Ok(true); + } } Operation::Delete => { - entities.remove(&frame.key); - output::print_delete(&frame.entity, &frame.key)?; + state.entities.remove(&frame.key); + state.entity_count = state.entities.len() as u64; + state.update_count += 1; + if state.count_only { + output::print_count(state.update_count)?; + } else { + match state.output_mode { + OutputMode::NoDna => output::emit_no_dna_event( + "entity_update", view, + &serde_json::json!({"key": frame.key, "op": "delete", "data": null}), + state.update_count, state.entity_count, + )?, + _ => output::print_delete(view, &frame.key)?, + } + } } - Operation::Subscribed => { - // Handled in the text message branch + Operation::Subscribed => {} + } + + Ok(false) +} + +/// Emit an entity through filter + select + output. Returns true if --first should trigger. +fn emit_entity( + state: &mut StreamState, + view: &str, + key: &str, + op: &str, + data: &serde_json::Value, +) -> Result { + // Apply filter + if !state.filter.is_empty() && !state.filter.matches(data) { + return Ok(false); + } + + state.update_count += 1; + + // Apply field selection + let output_data = match &state.select_fields { + Some(fields) => filter::select_fields(data, fields), + None => data.clone(), + }; + + if state.count_only { + output::print_count(state.update_count)?; + } else { + match state.output_mode { + OutputMode::NoDna => output::emit_no_dna_event( + "entity_update", view, + &serde_json::json!({"key": key, "op": op, "data": output_data}), + state.update_count, state.entity_count, + )?, + _ => output::print_entity_update(view, key, op, &output_data)?, } } - Ok(()) + // --first: exit after first filter match + if state.first && !state.filter.is_empty() { + return Ok(true); + } + + Ok(false) } diff --git a/cli/src/commands/stream/filter.rs b/cli/src/commands/stream/filter.rs new file mode 100644 index 00000000..6f3f256b --- /dev/null +++ b/cli/src/commands/stream/filter.rs @@ -0,0 +1,313 @@ +use anyhow::{bail, Result}; +use regex::Regex; +use serde_json::Value; + +#[derive(Debug, Clone)] +pub struct Filter { + pub predicates: Vec, +} + +#[derive(Debug, Clone)] +pub struct Predicate { + pub path: Vec, + pub op: FilterOp, +} + +#[derive(Debug, Clone)] +pub enum FilterOp { + Eq(String), + NotEq(String), + Gt(f64), + Gte(f64), + Lt(f64), + Lte(f64), + Regex(Regex), + NotRegex(Regex), + Exists, + NotExists, +} + +impl Filter { + pub fn parse(exprs: &[String]) -> Result { + let predicates = exprs + .iter() + .map(|expr| parse_predicate(expr)) + .collect::>>()?; + Ok(Filter { predicates }) + } + + pub fn is_empty(&self) -> bool { + self.predicates.is_empty() + } + + pub fn matches(&self, value: &Value) -> bool { + self.predicates.iter().all(|p| p.matches(value)) + } +} + +impl Predicate { + fn matches(&self, value: &Value) -> bool { + let resolved = resolve_path(value, &self.path); + + match &self.op { + FilterOp::Exists => resolved.is_some() && !resolved.unwrap().is_null(), + FilterOp::NotExists => resolved.is_none() || resolved.unwrap().is_null(), + FilterOp::Eq(expected) => match resolved { + Some(v) => value_eq(v, expected), + None => false, + }, + FilterOp::NotEq(expected) => match resolved { + Some(v) => !value_eq(v, expected), + None => true, + }, + FilterOp::Gt(n) => resolved.and_then(as_f64).map_or(false, |v| v > *n), + FilterOp::Gte(n) => resolved.and_then(as_f64).map_or(false, |v| v >= *n), + FilterOp::Lt(n) => resolved.and_then(as_f64).map_or(false, |v| v < *n), + FilterOp::Lte(n) => resolved.and_then(as_f64).map_or(false, |v| v <= *n), + FilterOp::Regex(re) => resolved + .and_then(|v| v.as_str()) + .map_or(false, |s| re.is_match(s)), + FilterOp::NotRegex(re) => resolved + .and_then(|v| v.as_str()) + .map_or(true, |s| !re.is_match(s)), + } + } +} + +fn resolve_path<'a>(value: &'a Value, path: &[String]) -> Option<&'a Value> { + let mut current = value; + for segment in path { + current = current.get(segment)?; + } + Some(current) +} + +fn value_eq(value: &Value, expected: &str) -> bool { + match value { + Value::String(s) => s == expected, + Value::Number(n) => { + if let Ok(expected_n) = expected.parse::() { + n.as_f64().map_or(false, |v| v == expected_n) + } else { + n.to_string() == expected + } + } + Value::Bool(b) => { + (expected == "true" && *b) || (expected == "false" && !b) + } + Value::Null => expected == "null", + _ => { + let s = serde_json::to_string(value).unwrap_or_default(); + s == expected + } + } +} + +fn as_f64(value: &Value) -> Option { + match value { + Value::Number(n) => n.as_f64(), + Value::String(s) => s.parse::().ok(), + _ => None, + } +} + +fn parse_predicate(expr: &str) -> Result { + let expr = expr.trim(); + + // Existence: field? or field!? + if expr.ends_with("!?") { + let field = &expr[..expr.len() - 2]; + return Ok(Predicate { + path: parse_path(field), + op: FilterOp::NotExists, + }); + } + if expr.ends_with('?') { + let field = &expr[..expr.len() - 1]; + return Ok(Predicate { + path: parse_path(field), + op: FilterOp::Exists, + }); + } + + // Two-char operators: !=, >=, <=, !~ + for (op_str, make_op) in &[ + ("!=", make_not_eq as fn(&str) -> Result), + (">=", make_gte as fn(&str) -> Result), + ("<=", make_lte as fn(&str) -> Result), + ("!~", make_not_regex as fn(&str) -> Result), + ] { + if let Some(idx) = expr.find(op_str) { + let field = &expr[..idx]; + let value = &expr[idx + op_str.len()..]; + return Ok(Predicate { + path: parse_path(field), + op: make_op(value)?, + }); + } + } + + // Single-char operators: =, >, <, ~ + for (op_char, make_op) in &[ + ('>', make_gt as fn(&str) -> Result), + ('<', make_lt as fn(&str) -> Result), + ('~', make_regex as fn(&str) -> Result), + ('=', make_eq as fn(&str) -> Result), + ] { + if let Some(idx) = expr.find(*op_char) { + let field = &expr[..idx]; + let value = &expr[idx + 1..]; + return Ok(Predicate { + path: parse_path(field), + op: make_op(value)?, + }); + } + } + + bail!( + "Invalid filter expression: '{}'\n\ + Expected: field=value, field>N, field~regex, field?, etc.", + expr + ) +} + +fn parse_path(field: &str) -> Vec { + field.split('.').map(|s| s.to_string()).collect() +} + +fn make_eq(value: &str) -> Result { + Ok(FilterOp::Eq(value.to_string())) +} + +fn make_not_eq(value: &str) -> Result { + Ok(FilterOp::NotEq(value.to_string())) +} + +fn make_gt(value: &str) -> Result { + let n: f64 = value.parse().map_err(|_| anyhow::anyhow!("Expected number after '>', got '{}'", value))?; + Ok(FilterOp::Gt(n)) +} + +fn make_gte(value: &str) -> Result { + let n: f64 = value.parse().map_err(|_| anyhow::anyhow!("Expected number after '>=', got '{}'", value))?; + Ok(FilterOp::Gte(n)) +} + +fn make_lt(value: &str) -> Result { + let n: f64 = value.parse().map_err(|_| anyhow::anyhow!("Expected number after '<', got '{}'", value))?; + Ok(FilterOp::Lt(n)) +} + +fn make_lte(value: &str) -> Result { + let n: f64 = value.parse().map_err(|_| anyhow::anyhow!("Expected number after '<=', got '{}'", value))?; + Ok(FilterOp::Lte(n)) +} + +fn make_regex(value: &str) -> Result { + let re = Regex::new(value).map_err(|e| anyhow::anyhow!("Invalid regex '{}': {}", value, e))?; + Ok(FilterOp::Regex(re)) +} + +fn make_not_regex(value: &str) -> Result { + let re = Regex::new(value).map_err(|e| anyhow::anyhow!("Invalid regex '{}': {}", value, e))?; + Ok(FilterOp::NotRegex(re)) +} + +/// Project specific fields from a JSON value. +/// Returns a new object with only the selected dot-paths. +pub fn select_fields(value: &Value, fields: &[Vec]) -> Value { + let mut result = serde_json::Map::new(); + for path in fields { + if let Some(v) = resolve_path(value, path) { + // Use the leaf field name as the key in flat projection + let key = path.last().map(|s| s.as_str()).unwrap_or(""); + if !key.is_empty() { + result.insert(key.to_string(), v.clone()); + } + } + } + Value::Object(result) +} + +pub fn parse_select(select: &str) -> Vec> { + select + .split(',') + .map(|s| s.trim().split('.').map(|p| p.to_string()).collect()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_eq_string() { + let f = Filter::parse(&["name=alice".to_string()]).unwrap(); + assert!(f.matches(&json!({"name": "alice"}))); + assert!(!f.matches(&json!({"name": "bob"}))); + } + + #[test] + fn test_eq_number() { + let f = Filter::parse(&["age=30".to_string()]).unwrap(); + assert!(f.matches(&json!({"age": 30}))); + assert!(!f.matches(&json!({"age": 31}))); + } + + #[test] + fn test_gt() { + let f = Filter::parse(&["score>100".to_string()]).unwrap(); + assert!(f.matches(&json!({"score": 150}))); + assert!(!f.matches(&json!({"score": 50}))); + assert!(!f.matches(&json!({"score": 100}))); + } + + #[test] + fn test_nested_path() { + let f = Filter::parse(&["user.name=alice".to_string()]).unwrap(); + assert!(f.matches(&json!({"user": {"name": "alice"}}))); + assert!(!f.matches(&json!({"user": {"name": "bob"}}))); + } + + #[test] + fn test_exists() { + let f = Filter::parse(&["email?".to_string()]).unwrap(); + assert!(f.matches(&json!({"email": "a@b.com"}))); + assert!(!f.matches(&json!({"name": "alice"}))); + assert!(!f.matches(&json!({"email": null}))); + } + + #[test] + fn test_not_exists() { + let f = Filter::parse(&["email!?".to_string()]).unwrap(); + assert!(!f.matches(&json!({"email": "a@b.com"}))); + assert!(f.matches(&json!({"name": "alice"}))); + } + + #[test] + fn test_regex() { + let f = Filter::parse(&["name~^ali".to_string()]).unwrap(); + assert!(f.matches(&json!({"name": "alice"}))); + assert!(!f.matches(&json!({"name": "bob"}))); + } + + #[test] + fn test_multiple_filters_and() { + let f = Filter::parse(&[ + "age>18".to_string(), + "name=alice".to_string(), + ]).unwrap(); + assert!(f.matches(&json!({"age": 25, "name": "alice"}))); + assert!(!f.matches(&json!({"age": 25, "name": "bob"}))); + assert!(!f.matches(&json!({"age": 15, "name": "alice"}))); + } + + #[test] + fn test_select_fields() { + let v = json!({"name": "alice", "age": 30, "nested": {"x": 1}}); + let fields = parse_select("name,nested.x"); + let result = select_fields(&v, &fields); + assert_eq!(result, json!({"name": "alice", "x": 1})); + } +} diff --git a/cli/src/commands/stream/mod.rs b/cli/src/commands/stream/mod.rs index a35be68e..692d7470 100644 --- a/cli/src/commands/stream/mod.rs +++ b/cli/src/commands/stream/mod.rs @@ -1,4 +1,5 @@ mod client; +mod filter; mod output; use anyhow::{bail, Context, Result}; @@ -27,6 +28,30 @@ pub struct StreamArgs { #[arg(long)] pub raw: bool, + /// NO_DNA agent-friendly envelope format + #[arg(long)] + pub no_dna: bool, + + /// Filter expression: field=value, field>N, field~regex (repeatable, ANDed) + #[arg(long = "where", value_name = "EXPR")] + pub filters: Vec, + + /// Select specific fields to output (comma-separated dot paths) + #[arg(long)] + pub select: Option, + + /// Exit after first entity matches filter criteria + #[arg(long)] + pub first: bool, + + /// Filter by operation type (comma-separated: upsert,patch,delete) + #[arg(long)] + pub ops: Option, + + /// Show running count of entities/updates only + #[arg(long)] + pub count: bool, + /// Max entities in snapshot #[arg(long)] pub take: Option, diff --git a/cli/src/commands/stream/output.rs b/cli/src/commands/stream/output.rs index 6eab2dde..0d51fb95 100644 --- a/cli/src/commands/stream/output.rs +++ b/cli/src/commands/stream/output.rs @@ -2,6 +2,12 @@ use anyhow::Result; use hyperstack_sdk::Frame; use std::io::{self, Write}; +pub enum OutputMode { + Raw, + Merged, + NoDna, +} + /// Print a raw WebSocket frame as a single JSON line to stdout. pub fn print_raw_frame(frame: &Frame) -> Result<()> { let line = serde_json::to_string(frame)?; @@ -12,7 +18,6 @@ pub fn print_raw_frame(frame: &Frame) -> Result<()> { } /// Print a merged entity update as a single JSON line to stdout. -/// Output format: {"view": "...", "key": "...", "op": "...", "data": {...}} pub fn print_entity_update( view: &str, key: &str, @@ -46,3 +51,38 @@ pub fn print_delete(view: &str, key: &str) -> Result<()> { writeln!(out, "{}", line)?; Ok(()) } + +/// Print a running update count to stderr (overwrites line). +pub fn print_count(count: u64) -> Result<()> { + eprint!("\rUpdates: {}", count); + Ok(()) +} + +/// Emit a NO_DNA envelope event as a single JSON line to stdout. +pub fn emit_no_dna_event( + action: &str, + view: &str, + data: &serde_json::Value, + update_count: u64, + entity_count: u64, +) -> Result<()> { + let output = serde_json::json!({ + "schema": "no-dna/v1", + "tool": "hs-stream", + "action": action, + "status": if action == "disconnected" || action == "error" { "done" } else { "streaming" }, + "data": { + "view": view, + "payload": data, + }, + "meta": { + "update_count": update_count, + "entities_tracked": entity_count, + }, + }); + let line = serde_json::to_string(&output)?; + let stdout = io::stdout(); + let mut out = stdout.lock(); + writeln!(out, "{}", line)?; + Ok(()) +} From 6c114029f8acd8961a9dde76420daf41a6a9b16d Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:16:56 +0000 Subject: [PATCH 03/97] feat: add --save/--load snapshot recording and replay to hs stream (Phase 3) - --save records all raw frames with timestamps to a JSON file - --duration auto-stops recording after N seconds - --load replays a saved snapshot through the same merge/filter pipeline (no WebSocket connection needed) - Snapshot format includes metadata (view, url, captured_at, duration) --- cli/src/commands/stream/client.rs | 120 ++++++++++++++++++--------- cli/src/commands/stream/mod.rs | 22 +++++ cli/src/commands/stream/snapshot.rs | 121 ++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+), 36 deletions(-) create mode 100644 cli/src/commands/stream/snapshot.rs diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 700ad8bd..4f0ee017 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -9,6 +9,7 @@ use tokio_tungstenite::{connect_async, tungstenite::Message}; use super::filter::{self, Filter}; use super::output::{self, OutputMode}; +use super::snapshot::{SnapshotPlayer, SnapshotRecorder}; use super::StreamArgs; struct StreamState { @@ -21,6 +22,41 @@ struct StreamState { count_only: bool, update_count: u64, entity_count: u64, + recorder: Option, +} + +fn build_state(args: &StreamArgs, view: &str, url: &str) -> Result { + let filter = Filter::parse(&args.filters)?; + let select_fields = args.select.as_deref().map(filter::parse_select); + let allowed_ops = args.ops.as_deref().map(|ops| { + ops.split(',') + .map(|s| s.trim().to_lowercase()) + .collect::>() + }); + + let output_mode = if args.raw { + OutputMode::Raw + } else if args.no_dna { + output::emit_no_dna_event("connected", view, &serde_json::json!({"url": url}), 0, 0)?; + OutputMode::NoDna + } else { + OutputMode::Merged + }; + + let recorder = args.save.as_ref().map(|_| SnapshotRecorder::new(view, url)); + + Ok(StreamState { + entities: HashMap::new(), + filter, + select_fields, + allowed_ops, + output_mode, + first: args.first, + count_only: args.count, + update_count: 0, + entity_count: 0, + recorder, + }) } pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { @@ -58,39 +94,16 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { .await .context("Failed to send subscribe message")?; - // Parse filters - let filter = Filter::parse(&args.filters)?; - let select_fields = args.select.as_deref().map(filter::parse_select); - let allowed_ops = args.ops.as_deref().map(|ops| { - ops.split(',') - .map(|s| s.trim().to_lowercase()) - .collect::>() - }); - - let output_mode = if args.raw { - OutputMode::Raw - } else if args.no_dna { - output::emit_no_dna_event("connected", view, &serde_json::json!({"url": url}), 0, 0)?; - OutputMode::NoDna - } else { - OutputMode::Merged - }; - - let mut state = StreamState { - entities: HashMap::new(), - filter, - select_fields, - allowed_ops, - output_mode, - first: args.first, - count_only: args.count, - update_count: 0, - entity_count: 0, - }; + let mut state = build_state(args, view, &url)?; // Ping interval let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(30)); + // Duration timer for --save --duration + let duration_deadline = args.duration.map(|secs| { + tokio::time::Instant::now() + std::time::Duration::from_secs(secs) + }); + // Handle Ctrl+C let shutdown = tokio::signal::ctrl_c(); tokio::pin!(shutdown); @@ -98,6 +111,14 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { let mut snapshot_complete = false; loop { + // Check duration deadline + if let Some(deadline) = duration_deadline { + if tokio::time::Instant::now() >= deadline { + eprintln!("Duration reached, stopping..."); + break; + } + } + tokio::select! { msg = ws_rx.next() => { match msg { @@ -105,7 +126,7 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { match parse_frame(&bytes) { Ok(frame) => { if process_frame(frame, view, &mut state)? { - return Ok(()); + break; } } Err(e) => eprintln!("Warning: failed to parse frame: {}", e), @@ -122,9 +143,8 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { Ok(frame) => { let was_snapshot = frame.is_snapshot(); if process_frame(frame, view, &mut state)? { - return Ok(()); + break; } - // Detect snapshot completion (first non-snapshot after snapshots) if !was_snapshot && !snapshot_complete && state.update_count > 0 { snapshot_complete = true; if let OutputMode::NoDna = state.output_mode { @@ -170,6 +190,32 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { } } + // Save snapshot if --save was specified + if let (Some(save_path), Some(recorder)) = (&args.save, &state.recorder) { + recorder.save(save_path)?; + } + + if let OutputMode::NoDna = state.output_mode { + output::emit_no_dna_event( + "disconnected", view, + &serde_json::json!(null), + state.update_count, state.entity_count, + )?; + } + + Ok(()) +} + +/// Replay frames from a saved snapshot file through the same processing pipeline. +pub async fn replay(player: SnapshotPlayer, view: &str, args: &StreamArgs) -> Result<()> { + let mut state = build_state(args, view, &player.header.url)?; + + for snapshot_frame in &player.frames { + if process_frame(snapshot_frame.frame.clone(), view, &mut state)? { + break; + } + } + if let OutputMode::NoDna = state.output_mode { output::emit_no_dna_event( "disconnected", view, @@ -178,6 +224,7 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { )?; } + eprintln!("Replay complete: {} updates processed.", state.update_count); Ok(()) } @@ -187,6 +234,11 @@ fn process_frame( view: &str, state: &mut StreamState, ) -> Result { + // Record frame if --save is active + if let Some(recorder) = &mut state.recorder { + recorder.record(&frame); + } + let op = frame.operation(); let op_str = &frame.op; @@ -198,7 +250,6 @@ fn process_frame( } if let OutputMode::Raw = state.output_mode { - // In raw mode, apply filter to the frame data directly if !state.filter.is_empty() && !state.filter.matches(&frame.data) { return Ok(false); } @@ -271,14 +322,12 @@ fn emit_entity( op: &str, data: &serde_json::Value, ) -> Result { - // Apply filter if !state.filter.is_empty() && !state.filter.matches(data) { return Ok(false); } state.update_count += 1; - // Apply field selection let output_data = match &state.select_fields { Some(fields) => filter::select_fields(data, fields), None => data.clone(), @@ -297,7 +346,6 @@ fn emit_entity( } } - // --first: exit after first filter match if state.first && !state.filter.is_empty() { return Ok(true); } diff --git a/cli/src/commands/stream/mod.rs b/cli/src/commands/stream/mod.rs index 692d7470..8071c2ea 100644 --- a/cli/src/commands/stream/mod.rs +++ b/cli/src/commands/stream/mod.rs @@ -1,6 +1,7 @@ mod client; mod filter; mod output; +mod snapshot; use anyhow::{bail, Context, Result}; use clap::Args; @@ -67,9 +68,30 @@ pub struct StreamArgs { /// Resume from cursor (seq value) #[arg(long)] pub after: Option, + + /// Record frames to a JSON snapshot file + #[arg(long)] + pub save: Option, + + /// Auto-stop recording after N seconds (used with --save) + #[arg(long)] + pub duration: Option, + + /// Replay a previously saved snapshot file instead of connecting live + #[arg(long, conflicts_with = "url")] + pub load: Option, } pub fn run(args: StreamArgs, config_path: &str) -> Result<()> { + // --load mode: replay from file, no WebSocket needed + if let Some(load_path) = &args.load { + let player = snapshot::SnapshotPlayer::load(load_path)?; + let default_view = player.header.view.clone(); + let view = args.view.as_deref().unwrap_or(&default_view); + let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?; + return rt.block_on(client::replay(player, view, &args)); + } + let view = args.view.as_deref().unwrap_or_else(|| { eprintln!("Error: argument is required (e.g. OreRound/latest)"); std::process::exit(1); diff --git a/cli/src/commands/stream/snapshot.rs b/cli/src/commands/stream/snapshot.rs new file mode 100644 index 00000000..92924232 --- /dev/null +++ b/cli/src/commands/stream/snapshot.rs @@ -0,0 +1,121 @@ +use anyhow::{Context, Result}; +use hyperstack_sdk::Frame; +use serde::{Deserialize, Serialize}; +use std::fs; + +#[derive(Debug, Serialize, Deserialize)] +pub struct SnapshotHeader { + pub version: u32, + pub view: String, + pub url: String, + pub captured_at: String, + pub duration_ms: u64, + pub frame_count: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SnapshotFrame { + pub ts: u64, + pub frame: Frame, +} + +pub struct SnapshotRecorder { + frames: Vec, + view: String, + url: String, + start_time: std::time::Instant, + start_timestamp: chrono::DateTime, +} + +impl SnapshotRecorder { + pub fn new(view: &str, url: &str) -> Self { + Self { + frames: Vec::new(), + view: view.to_string(), + url: url.to_string(), + start_time: std::time::Instant::now(), + start_timestamp: chrono::Utc::now(), + } + } + + pub fn record(&mut self, frame: &Frame) { + let ts = self.start_time.elapsed().as_millis() as u64; + self.frames.push(SnapshotFrame { + ts, + frame: frame.clone(), + }); + } + + pub fn save(&self, path: &str) -> Result<()> { + let duration_ms = self.start_time.elapsed().as_millis() as u64; + let header = SnapshotHeader { + version: 1, + view: self.view.clone(), + url: self.url.clone(), + captured_at: self.start_timestamp.to_rfc3339(), + duration_ms, + frame_count: self.frames.len() as u64, + }; + + let output = serde_json::json!({ + "version": header.version, + "view": header.view, + "url": header.url, + "captured_at": header.captured_at, + "duration_ms": header.duration_ms, + "frame_count": header.frame_count, + "frames": self.frames, + }); + + let json = serde_json::to_string_pretty(&output)?; + fs::write(path, json) + .with_context(|| format!("Failed to write snapshot to {}", path))?; + + eprintln!( + "Saved {} frames ({:.1}s) to {}", + self.frames.len(), + duration_ms as f64 / 1000.0, + path + ); + Ok(()) + } +} + +pub struct SnapshotPlayer { + pub header: SnapshotHeader, + pub frames: Vec, +} + +impl SnapshotPlayer { + pub fn load(path: &str) -> Result { + let contents = fs::read_to_string(path) + .with_context(|| format!("Failed to read snapshot file: {}", path))?; + + let value: serde_json::Value = serde_json::from_str(&contents) + .with_context(|| format!("Failed to parse snapshot JSON: {}", path))?; + + let header = SnapshotHeader { + version: value.get("version").and_then(|v| v.as_u64()).unwrap_or(1) as u32, + view: value.get("view").and_then(|v| v.as_str()).unwrap_or("").to_string(), + url: value.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string(), + captured_at: value.get("captured_at").and_then(|v| v.as_str()).unwrap_or("").to_string(), + duration_ms: value.get("duration_ms").and_then(|v| v.as_u64()).unwrap_or(0), + frame_count: value.get("frame_count").and_then(|v| v.as_u64()).unwrap_or(0), + }; + + let frames: Vec = value + .get("frames") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + eprintln!( + "Loaded snapshot: {} frames, {:.1}s, view={}, captured={}", + frames.len(), + header.duration_ms as f64 / 1000.0, + header.view, + header.captured_at, + ); + + Ok(Self { header, frames }) + } +} From af897c3b4f9db40a0210c89501424e7a47632fee Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:22:46 +0000 Subject: [PATCH 04/97] feat: add EntityStore with history, --history, --at, --diff flags (Phase 4) EntityStore tracks full entity state + per-entity history ring buffer (default 1000 entries). Supports: - --history: outputs full update history for --key entity as JSON array - --at N: shows entity state at specific history index (0 = latest) - --diff: shows field-level diff (added/changed/removed) between updates with raw patch data when available These flags provide non-interactive agent equivalents of the TUI time travel feature. 5 unit tests for store operations and diffing. --- cli/src/commands/stream/client.rs | 78 ++++++++ cli/src/commands/stream/mod.rs | 13 ++ cli/src/commands/stream/store.rs | 288 ++++++++++++++++++++++++++++++ 3 files changed, 379 insertions(+) create mode 100644 cli/src/commands/stream/store.rs diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 4f0ee017..3b8ea22d 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -10,10 +10,12 @@ use tokio_tungstenite::{connect_async, tungstenite::Message}; use super::filter::{self, Filter}; use super::output::{self, OutputMode}; use super::snapshot::{SnapshotPlayer, SnapshotRecorder}; +use super::store::EntityStore; use super::StreamArgs; struct StreamState { entities: HashMap, + store: Option, filter: Filter, select_fields: Option>>, allowed_ops: Option>, @@ -45,8 +47,16 @@ fn build_state(args: &StreamArgs, view: &str, url: &str) -> Result let recorder = args.save.as_ref().map(|_| SnapshotRecorder::new(view, url)); + let use_store = args.history || args.at.is_some() || args.diff; + let store = if use_store { + Some(EntityStore::new()) + } else { + None + }; + Ok(StreamState { entities: HashMap::new(), + store, filter, select_fields, allowed_ops, @@ -203,6 +213,9 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { )?; } + // Output history/at/diff after stream ends (for non-interactive agent use) + output_history_if_requested(&state, args)?; + Ok(()) } @@ -224,10 +237,63 @@ pub async fn replay(player: SnapshotPlayer, view: &str, args: &StreamArgs) -> Re )?; } + output_history_if_requested(&state, args)?; + eprintln!("Replay complete: {} updates processed.", state.update_count); Ok(()) } +/// After the stream ends, output --history / --at / --diff results for the specified --key. +fn output_history_if_requested(state: &StreamState, args: &StreamArgs) -> Result<()> { + let store = match &state.store { + Some(s) => s, + None => return Ok(()), + }; + + let key = match &args.key { + Some(k) => k.as_str(), + None => { + if args.history || args.at.is_some() || args.diff { + eprintln!("Warning: --history/--at/--diff require --key to specify which entity"); + } + return Ok(()); + } + }; + + if args.diff { + let index = args.at.unwrap_or(0); + if let Some(diff) = store.diff_at(key, index) { + let line = serde_json::to_string_pretty(&diff)?; + println!("{}", line); + } else { + eprintln!("No history entry at index {} for key '{}'", index, key); + } + } else if let Some(index) = args.at { + if let Some(entry) = store.at(key, index) { + let output = serde_json::json!({ + "key": key, + "index": index, + "op": entry.op, + "seq": entry.seq, + "state": entry.state, + }); + let line = serde_json::to_string_pretty(&output)?; + println!("{}", line); + } else { + eprintln!("No history entry at index {} for key '{}'", index, key); + } + } else if args.history { + if let Some(history) = store.history(key) { + let line = serde_json::to_string_pretty(&history)?; + println!("{}", line); + } else { + eprintln!("No history found for key '{}'", key); + } + } + + Ok(()) +} + /// Process a frame. Returns true if the stream should end (--first matched). fn process_frame( frame: Frame, @@ -267,6 +333,9 @@ fn process_frame( let snapshot_entities = parse_snapshot_entities(&frame.data); for entity in snapshot_entities { state.entities.insert(entity.key.clone(), entity.data.clone()); + if let Some(store) = &mut state.store { + store.upsert(&entity.key, entity.data.clone(), "snapshot", None); + } state.entity_count = state.entities.len() as u64; if emit_entity(state, view, &entity.key, "snapshot", &entity.data)? { return Ok(true); @@ -275,12 +344,18 @@ fn process_frame( } Operation::Upsert | Operation::Create => { state.entities.insert(frame.key.clone(), frame.data.clone()); + if let Some(store) = &mut state.store { + store.upsert(&frame.key, frame.data.clone(), op_str, frame.seq.clone()); + } state.entity_count = state.entities.len() as u64; if emit_entity(state, view, &frame.key, op_str, &frame.data)? { return Ok(true); } } Operation::Patch => { + if let Some(store) = &mut state.store { + store.patch(&frame.key, &frame.data, &frame.append, frame.seq.clone()); + } let entry = state.entities .entry(frame.key.clone()) .or_insert_with(|| serde_json::json!({})); @@ -293,6 +368,9 @@ fn process_frame( } Operation::Delete => { state.entities.remove(&frame.key); + if let Some(store) = &mut state.store { + store.delete(&frame.key); + } state.entity_count = state.entities.len() as u64; state.update_count += 1; if state.count_only { diff --git a/cli/src/commands/stream/mod.rs b/cli/src/commands/stream/mod.rs index 8071c2ea..52ab2ca0 100644 --- a/cli/src/commands/stream/mod.rs +++ b/cli/src/commands/stream/mod.rs @@ -2,6 +2,7 @@ mod client; mod filter; mod output; mod snapshot; +mod store; use anyhow::{bail, Context, Result}; use clap::Args; @@ -80,6 +81,18 @@ pub struct StreamArgs { /// Replay a previously saved snapshot file instead of connecting live #[arg(long, conflicts_with = "url")] pub load: Option, + + /// Show update history for the specified --key entity + #[arg(long)] + pub history: bool, + + /// Show entity at a specific history index (0 = latest) + #[arg(long)] + pub at: Option, + + /// Show diff between consecutive updates + #[arg(long)] + pub diff: bool, } pub fn run(args: StreamArgs, config_path: &str) -> Result<()> { diff --git a/cli/src/commands/stream/store.rs b/cli/src/commands/stream/store.rs new file mode 100644 index 00000000..33ae731c --- /dev/null +++ b/cli/src/commands/stream/store.rs @@ -0,0 +1,288 @@ +use hyperstack_sdk::deep_merge_with_append; +use serde_json::Value; +use std::collections::{HashMap, VecDeque}; + +const DEFAULT_MAX_HISTORY: usize = 1000; + +pub struct EntityStore { + entities: HashMap, + max_history: usize, +} + +pub struct EntityRecord { + pub current: Value, + pub history: VecDeque, +} + +#[derive(Clone)] +pub struct HistoryEntry { + pub seq: Option, + pub op: String, + pub state: Value, + pub patch: Option, +} + +#[allow(dead_code)] +impl EntityStore { + pub fn new() -> Self { + Self { + entities: HashMap::new(), + max_history: DEFAULT_MAX_HISTORY, + } + } + + pub fn entity_count(&self) -> usize { + self.entities.len() + } + + pub fn get(&self, key: &str) -> Option<&EntityRecord> { + self.entities.get(key) + } + + /// Apply an upsert/create operation. Returns the full entity state. + pub fn upsert(&mut self, key: &str, data: Value, op: &str, seq: Option) -> &Value { + let record = self.entities.entry(key.to_string()).or_insert_with(|| { + EntityRecord { + current: Value::Null, + history: VecDeque::new(), + } + }); + + record.current = data.clone(); + record.history.push_back(HistoryEntry { + seq, + op: op.to_string(), + state: data, + patch: None, + }); + + if record.history.len() > self.max_history { + record.history.pop_front(); + } + + &record.current + } + + /// Apply a patch operation. Returns the merged entity state. + pub fn patch( + &mut self, + key: &str, + patch_data: &Value, + append_paths: &[String], + seq: Option, + ) -> &Value { + let record = self.entities.entry(key.to_string()).or_insert_with(|| { + EntityRecord { + current: serde_json::json!({}), + history: VecDeque::new(), + } + }); + + let raw_patch = patch_data.clone(); + deep_merge_with_append(&mut record.current, patch_data, append_paths, ""); + + record.history.push_back(HistoryEntry { + seq, + op: "patch".to_string(), + state: record.current.clone(), + patch: Some(raw_patch), + }); + + if record.history.len() > self.max_history { + record.history.pop_front(); + } + + &record.current + } + + /// Remove an entity. + pub fn delete(&mut self, key: &str) { + self.entities.remove(key); + } + + /// Get entity state at a specific history index (0 = latest). + pub fn at(&self, key: &str, index: usize) -> Option<&HistoryEntry> { + let record = self.entities.get(key)?; + if index >= record.history.len() { + return None; + } + let actual_idx = record.history.len() - 1 - index; + record.history.get(actual_idx) + } + + /// Get the diff between two consecutive history entries. + /// Returns (added/changed fields, removed fields). + pub fn diff_at(&self, key: &str, index: usize) -> Option { + let record = self.entities.get(key)?; + if record.history.is_empty() { + return None; + } + + let actual_idx = record.history.len().checked_sub(1 + index)?; + let current = &record.history.get(actual_idx)?.state; + + // If this entry has a raw patch, use it directly + if let Some(patch) = &record.history.get(actual_idx)?.patch { + return Some(serde_json::json!({ + "op": record.history.get(actual_idx)?.op, + "index": index, + "total": record.history.len(), + "patch": patch, + "state": current, + })); + } + + // Otherwise diff against previous state + let previous = if actual_idx > 0 { + &record.history.get(actual_idx - 1)?.state + } else { + &Value::Null + }; + + let changes = compute_diff(previous, current); + Some(serde_json::json!({ + "op": record.history.get(actual_idx)?.op, + "index": index, + "total": record.history.len(), + "changes": changes, + "state": current, + })) + } + + /// Get the full history for an entity as a JSON array. + pub fn history(&self, key: &str) -> Option { + let record = self.entities.get(key)?; + let entries: Vec = record + .history + .iter() + .enumerate() + .rev() + .map(|(i, entry)| { + let rev_idx = record.history.len() - 1 - i; + serde_json::json!({ + "index": rev_idx, + "op": entry.op, + "seq": entry.seq, + "state": entry.state, + }) + }) + .collect(); + Some(Value::Array(entries)) + } +} + +/// Compute a simple diff between two JSON values. +fn compute_diff(old: &Value, new: &Value) -> Value { + match (old, new) { + (Value::Object(old_map), Value::Object(new_map)) => { + let mut diff = serde_json::Map::new(); + + for (key, new_val) in new_map { + match old_map.get(key) { + Some(old_val) if old_val != new_val => { + diff.insert( + key.clone(), + serde_json::json!({ + "from": old_val, + "to": new_val, + }), + ); + } + None => { + diff.insert( + key.clone(), + serde_json::json!({ + "added": new_val, + }), + ); + } + _ => {} + } + } + + for key in old_map.keys() { + if !new_map.contains_key(key) { + diff.insert( + key.clone(), + serde_json::json!({ + "removed": old_map.get(key), + }), + ); + } + } + + Value::Object(diff) + } + _ if old != new => { + serde_json::json!({ + "from": old, + "to": new, + }) + } + _ => Value::Null, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_upsert_and_history() { + let mut store = EntityStore::new(); + store.upsert("k1", json!({"a": 1}), "upsert", None); + store.upsert("k1", json!({"a": 2}), "upsert", None); + + assert_eq!(store.get("k1").unwrap().current, json!({"a": 2})); + assert_eq!(store.get("k1").unwrap().history.len(), 2); + + let at0 = store.at("k1", 0).unwrap(); + assert_eq!(at0.state, json!({"a": 2})); + + let at1 = store.at("k1", 1).unwrap(); + assert_eq!(at1.state, json!({"a": 1})); + } + + #[test] + fn test_patch() { + let mut store = EntityStore::new(); + store.upsert("k1", json!({"a": 1, "b": 2}), "upsert", None); + store.patch("k1", &json!({"a": 10}), &[], None); + + assert_eq!(store.get("k1").unwrap().current, json!({"a": 10, "b": 2})); + assert_eq!(store.get("k1").unwrap().history.len(), 2); + } + + #[test] + fn test_diff() { + let mut store = EntityStore::new(); + store.upsert("k1", json!({"a": 1, "b": 2}), "upsert", None); + store.patch("k1", &json!({"a": 10}), &[], None); + + let diff = store.diff_at("k1", 0).unwrap(); + // Latest entry is a patch, so it should include the raw patch + assert_eq!(diff["patch"], json!({"a": 10})); + } + + #[test] + fn test_delete() { + let mut store = EntityStore::new(); + store.upsert("k1", json!({"a": 1}), "upsert", None); + store.delete("k1"); + assert!(store.get("k1").is_none()); + } + + #[test] + fn test_compute_diff() { + let old = json!({"a": 1, "b": 2, "c": 3}); + let new = json!({"a": 1, "b": 5, "d": 4}); + let diff = compute_diff(&old, &new); + + assert!(diff.get("a").is_none()); // unchanged + assert_eq!(diff["b"]["from"], json!(2)); + assert_eq!(diff["b"]["to"], json!(5)); + assert_eq!(diff["c"]["removed"], json!(3)); + assert_eq!(diff["d"]["added"], json!(4)); + } +} From de9a7ea7a3a50bfdf4759ed62bb5d1cd4fdffc8a Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:20:40 +0000 Subject: [PATCH 05/97] feat: add interactive TUI mode for hs stream (Phase 5) Behind --features tui flag, `hs stream --tui` launches a ratatui-based terminal UI with: - Split-pane layout: entity list (30%) + detail view (70%) - Entity navigation (j/k, arrows), detail focus (Enter/Esc) - Time travel through entity history (h/l, Home/End) - Diff view toggle (d) showing field-level changes - JSON syntax coloring in detail panel - Pause/resume live updates (p) - Save snapshot to file (s) - Entity key filtering (/) - Raw frame toggle (r) - Status bar with keybinding hints - Timeline bar showing history position Dependencies: ratatui 0.29, crossterm 0.28 (optional) Without the tui feature, --tui prints an error with install instructions. --- Cargo.lock | 262 +++++++++++++++++++++++++- cli/Cargo.toml | 4 + cli/src/commands/stream/mod.rs | 23 ++- cli/src/commands/stream/tui/app.rs | 284 +++++++++++++++++++++++++++++ cli/src/commands/stream/tui/mod.rs | 175 ++++++++++++++++++ cli/src/commands/stream/tui/ui.rs | 265 +++++++++++++++++++++++++++ 6 files changed, 1004 insertions(+), 9 deletions(-) create mode 100644 cli/src/commands/stream/tui/app.rs create mode 100644 cli/src/commands/stream/tui/mod.rs create mode 100644 cli/src/commands/stream/tui/ui.rs diff --git a/Cargo.lock b/Cargo.lock index dad7e9d0..c8c63584 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -581,6 +581,21 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.51" @@ -694,6 +709,20 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "console" version = "0.15.11" @@ -703,7 +732,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.0", "windows-sys 0.59.0", ] @@ -833,6 +862,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "mio", + "parking_lot 0.12.5", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -901,6 +955,40 @@ dependencies = [ "syn 2.0.113", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.113", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.113", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -1660,6 +1748,7 @@ dependencies = [ "clap_complete", "colored", "console", + "crossterm", "dialoguer", "dirs", "flate2", @@ -1668,6 +1757,7 @@ dependencies = [ "hyperstack-interpreter", "hyperstack-sdk", "indicatif", + "ratatui", "regex", "reqwest 0.11.27", "rpassword", @@ -1906,6 +1996,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1956,10 +2052,19 @@ dependencies = [ "console", "number_prefix", "portable-atomic", - "unicode-width", + "unicode-width 0.2.0", "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" @@ -1969,6 +2074,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.113", +] + [[package]] name = "instant" version = "0.1.13" @@ -2009,6 +2127,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2132,6 +2259,12 @@ dependencies = [ "libsecp256k1-core", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -2245,6 +2378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -2563,6 +2697,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pbkdf2" version = "0.11.0" @@ -3046,6 +3186,27 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.10.0", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.2.16" @@ -3242,6 +3403,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.3" @@ -3251,7 +3425,7 @@ dependencies = [ "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] @@ -3587,6 +3761,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -5151,12 +5346,40 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.113", +] + [[package]] name = "subtle" version = "2.6.1" @@ -5252,7 +5475,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -5262,7 +5485,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "rustix", + "rustix 1.1.3", "windows-sys 0.60.2", ] @@ -5966,11 +6189,34 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "universal-hash" @@ -6546,7 +6792,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.3", ] [[package]] diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 4a8aba45..a2f83034 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -19,6 +19,8 @@ path = "src/main.rs" default = [] # Enable local development mode (uses http://localhost:3000 instead of production API) local = [] +# Enable interactive TUI for `hs stream --tui` +tui = ["ratatui", "crossterm"] [dependencies] clap = { version = "4.5", features = ["derive", "cargo"] } @@ -45,4 +47,6 @@ regex = "1.10" tokio = { version = "1.0", features = ["rt-multi-thread", "sync", "time", "macros", "signal"] } futures-util = { version = "0.3", features = ["sink"] } tokio-tungstenite = { version = "0.21", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } +ratatui = { version = "0.29", optional = true } +crossterm = { version = "0.28", optional = true } diff --git a/cli/src/commands/stream/mod.rs b/cli/src/commands/stream/mod.rs index 52ab2ca0..ffb908c3 100644 --- a/cli/src/commands/stream/mod.rs +++ b/cli/src/commands/stream/mod.rs @@ -3,6 +3,8 @@ mod filter; mod output; mod snapshot; mod store; +#[cfg(feature = "tui")] +mod tui; use anyhow::{bail, Context, Result}; use clap::Args; @@ -93,6 +95,10 @@ pub struct StreamArgs { /// Show diff between consecutive updates #[arg(long)] pub diff: bool, + + /// Interactive TUI mode + #[arg(long, short = 'i')] + pub tui: bool, } pub fn run(args: StreamArgs, config_path: &str) -> Result<()> { @@ -112,10 +118,25 @@ pub fn run(args: StreamArgs, config_path: &str) -> Result<()> { let url = resolve_url(&args, config_path, view)?; + let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?; + + if args.tui { + #[cfg(feature = "tui")] + { + return rt.block_on(tui::run_tui(url, view, &args)); + } + #[cfg(not(feature = "tui"))] + { + bail!( + "TUI mode requires the 'tui' feature.\n\ + Install with: cargo install hyperstack-cli --features tui" + ); + } + } + eprintln!("Connecting to {} ...", url); eprintln!("Subscribing to {} ...", view); - let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?; rt.block_on(client::stream(url, view, &args)) } diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs new file mode 100644 index 00000000..b397ff67 --- /dev/null +++ b/cli/src/commands/stream/tui/app.rs @@ -0,0 +1,284 @@ +use hyperstack_sdk::{parse_snapshot_entities, Frame, Operation}; + +use crate::commands::stream::snapshot::SnapshotRecorder; +use crate::commands::stream::store::EntityStore; + +const MAX_STATUS_AGE_MS: u128 = 3000; + +pub enum TuiAction { + Quit, + NextEntity, + PrevEntity, + FocusDetail, + BackToList, + HistoryForward, + HistoryBack, + HistoryOldest, + HistoryNewest, + ToggleDiff, + ToggleRaw, + TogglePause, + StartFilter, + SaveSnapshot, + FilterChar(char), + FilterBackspace, +} + +#[derive(Clone, Copy, PartialEq)] +pub enum ViewMode { + List, + Detail, +} + +#[allow(dead_code)] +pub struct App { + pub view: String, + pub url: String, + pub view_mode: ViewMode, + pub entity_keys: Vec, + pub selected_index: usize, + pub history_position: usize, + pub show_diff: bool, + pub show_raw: bool, + pub paused: bool, + pub filter_input_active: bool, + pub filter_text: String, + pub status_message: String, + pub status_time: std::time::Instant, + pub update_count: u64, + pub scroll_offset: u16, + store: EntityStore, + raw_frames: Vec, + recorder: Option, +} + +impl App { + pub fn new(view: String, url: String) -> Self { + Self { + view: view.clone(), + url: url.clone(), + view_mode: ViewMode::List, + entity_keys: Vec::new(), + selected_index: 0, + history_position: 0, + show_diff: false, + show_raw: false, + paused: false, + filter_input_active: false, + filter_text: String::new(), + status_message: "Connected".to_string(), + status_time: std::time::Instant::now(), + update_count: 0, + scroll_offset: 0, + store: EntityStore::new(), + raw_frames: Vec::new(), + recorder: Some(SnapshotRecorder::new(&view, &url)), + } + } + + pub fn apply_frame(&mut self, frame: Frame) { + // Record for save + if let Some(recorder) = &mut self.recorder { + recorder.record(&frame); + } + + let raw_frame = if self.show_raw { Some(frame.clone()) } else { None }; + let op = frame.operation(); + + match op { + Operation::Snapshot => { + let entities = parse_snapshot_entities(&frame.data); + for entity in entities { + self.store.upsert(&entity.key, entity.data, "snapshot", None); + if !self.entity_keys.contains(&entity.key) { + self.entity_keys.push(entity.key); + } + } + self.update_count += 1; + } + Operation::Upsert | Operation::Create => { + let key = frame.key.clone(); + let seq = frame.seq.clone(); + self.store + .upsert(&key, frame.data, &frame.op, seq); + if !self.entity_keys.contains(&key) { + self.entity_keys.push(key); + } + self.update_count += 1; + } + Operation::Patch => { + let key = frame.key.clone(); + let seq = frame.seq.clone(); + self.store + .patch(&key, &frame.data, &frame.append, seq); + if !self.entity_keys.contains(&key) { + self.entity_keys.push(key); + } + self.update_count += 1; + } + Operation::Delete => { + self.store.delete(&frame.key); + self.entity_keys.retain(|k| k != &frame.key); + self.update_count += 1; + if self.selected_index >= self.entity_keys.len() && self.selected_index > 0 { + self.selected_index -= 1; + } + } + Operation::Subscribed => { + self.set_status("Subscribed"); + } + } + + if let Some(raw) = raw_frame { + self.raw_frames.push(raw); + if self.raw_frames.len() > 1000 { + self.raw_frames.drain(0..500); + } + } + } + + pub fn handle_action(&mut self, action: TuiAction) { + match action { + TuiAction::Quit => {} + TuiAction::NextEntity => { + if !self.entity_keys.is_empty() { + self.selected_index = (self.selected_index + 1).min(self.entity_keys.len() - 1); + self.history_position = 0; + self.scroll_offset = 0; + } + } + TuiAction::PrevEntity => { + self.selected_index = self.selected_index.saturating_sub(1); + self.history_position = 0; + self.scroll_offset = 0; + } + TuiAction::FocusDetail => { + self.view_mode = ViewMode::Detail; + self.scroll_offset = 0; + } + TuiAction::BackToList => { + if self.filter_input_active { + self.filter_input_active = false; + } else { + self.view_mode = ViewMode::List; + self.scroll_offset = 0; + } + } + TuiAction::HistoryBack => { + self.history_position += 1; + self.scroll_offset = 0; + // Clamp to max history for selected entity + if let Some(key) = self.selected_key() { + if let Some(record) = self.store.get(&key) { + if self.history_position >= record.history.len() { + self.history_position = record.history.len().saturating_sub(1); + } + } + } + } + TuiAction::HistoryForward => { + self.history_position = self.history_position.saturating_sub(1); + self.scroll_offset = 0; + } + TuiAction::HistoryOldest => { + if let Some(key) = self.selected_key() { + if let Some(record) = self.store.get(&key) { + self.history_position = record.history.len().saturating_sub(1); + } + } + self.scroll_offset = 0; + } + TuiAction::HistoryNewest => { + self.history_position = 0; + self.scroll_offset = 0; + } + TuiAction::ToggleDiff => { + self.show_diff = !self.show_diff; + self.set_status(if self.show_diff { "Diff view ON" } else { "Diff view OFF" }); + } + TuiAction::ToggleRaw => { + self.show_raw = !self.show_raw; + self.set_status(if self.show_raw { "Raw frames ON" } else { "Raw frames OFF" }); + } + TuiAction::TogglePause => { + self.paused = !self.paused; + self.set_status(if self.paused { "PAUSED" } else { "Resumed" }); + } + TuiAction::StartFilter => { + self.filter_input_active = true; + self.filter_text.clear(); + } + TuiAction::SaveSnapshot => { + if let Some(recorder) = &self.recorder { + let filename = format!("hs-stream-{}.json", chrono::Utc::now().format("%Y%m%d-%H%M%S")); + match recorder.save(&filename) { + Ok(_) => self.set_status(&format!("Saved to {}", filename)), + Err(e) => self.set_status(&format!("Save failed: {}", e)), + } + } + } + TuiAction::FilterChar(c) => { + self.filter_text.push(c); + } + TuiAction::FilterBackspace => { + self.filter_text.pop(); + } + } + } + + pub fn selected_key(&self) -> Option { + self.entity_keys.get(self.selected_index).cloned() + } + + pub fn selected_entity_data(&self) -> Option { + let key = self.selected_key()?; + + if self.show_diff { + let diff = self.store.diff_at(&key, self.history_position)?; + return Some(serde_json::to_string_pretty(&diff).unwrap_or_default()); + } + + if self.history_position > 0 { + let entry = self.store.at(&key, self.history_position)?; + return Some(serde_json::to_string_pretty(&entry.state).unwrap_or_default()); + } + + let record = self.store.get(&key)?; + Some(serde_json::to_string_pretty(&record.current).unwrap_or_default()) + } + + pub fn selected_history_len(&self) -> usize { + self.selected_key() + .and_then(|k| self.store.get(&k)) + .map(|r| r.history.len()) + .unwrap_or(0) + } + + pub fn status(&self) -> &str { + if self.status_time.elapsed().as_millis() < MAX_STATUS_AGE_MS { + &self.status_message + } else if self.paused { + "PAUSED" + } else { + "Streaming" + } + } + + fn set_status(&mut self, msg: &str) { + self.status_message = msg.to_string(); + self.status_time = std::time::Instant::now(); + } + + pub fn filtered_keys(&self) -> Vec<&str> { + if self.filter_text.is_empty() { + self.entity_keys.iter().map(|s| s.as_str()).collect() + } else { + let lower = self.filter_text.to_lowercase(); + self.entity_keys + .iter() + .filter(|k| k.to_lowercase().contains(&lower)) + .map(|s| s.as_str()) + .collect() + } + } +} diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs new file mode 100644 index 00000000..4248564c --- /dev/null +++ b/cli/src/commands/stream/tui/mod.rs @@ -0,0 +1,175 @@ +mod app; +mod ui; + +use anyhow::{Context, Result}; +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use futures_util::{SinkExt, StreamExt}; +use hyperstack_sdk::{parse_frame, ClientMessage, Frame, Subscription}; +use ratatui::{backend::CrosstermBackend, Terminal}; +use std::io; +use tokio::sync::mpsc; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +use self::app::{App, TuiAction}; +use super::StreamArgs; + +pub async fn run_tui(url: String, view: &str, args: &StreamArgs) -> Result<()> { + // Connect WebSocket + let (ws, _) = connect_async(&url) + .await + .with_context(|| format!("Failed to connect to {}", url))?; + + let (mut ws_tx, mut ws_rx) = ws.split(); + + // Subscribe + let mut sub = Subscription::new(view); + if let Some(key) = &args.key { + sub = sub.with_key(key.clone()); + } + if let Some(take) = args.take { + sub = sub.with_take(take); + } + if let Some(skip) = args.skip { + sub = sub.with_skip(skip); + } + if args.no_snapshot { + sub = sub.with_snapshot(false); + } + if let Some(after) = &args.after { + sub = sub.after(after.clone()); + } + + let msg = serde_json::to_string(&ClientMessage::Subscribe(sub))?; + ws_tx.send(Message::Text(msg)).await?; + + // Channel for frames from WS task + let (frame_tx, mut frame_rx) = mpsc::channel::(1000); + + // Spawn WS reader task + let ws_handle = tokio::spawn(async move { + let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(30)); + loop { + tokio::select! { + msg = ws_rx.next() => { + match msg { + Some(Ok(Message::Binary(bytes))) => { + if let Ok(frame) = parse_frame(&bytes) { + if frame_tx.send(frame).await.is_err() { + break; + } + } + } + Some(Ok(Message::Text(text))) => { + if let Ok(frame) = serde_json::from_str::(&text) { + if frame_tx.send(frame).await.is_err() { + break; + } + } + } + Some(Ok(Message::Ping(payload))) => { + let _ = ws_tx.send(Message::Pong(payload)).await; + } + Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break, + _ => {} + } + } + _ = ping_interval.tick() => { + if let Ok(msg) = serde_json::to_string(&ClientMessage::Ping) { + let _ = ws_tx.send(Message::Text(msg)).await; + } + } + } + } + }); + + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut app = App::new(view.to_string(), url.clone()); + + // Main loop: poll terminal events + receive frames + let tick_rate = std::time::Duration::from_millis(50); + let result = run_loop(&mut terminal, &mut app, &mut frame_rx, tick_rate).await; + + // Restore terminal + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture, + )?; + terminal.show_cursor()?; + + ws_handle.abort(); + + result +} + +async fn run_loop( + terminal: &mut Terminal>, + app: &mut App, + frame_rx: &mut mpsc::Receiver, + tick_rate: std::time::Duration, +) -> Result<()> { + loop { + terminal.draw(|f| ui::draw(f, app))?; + + // Drain available frames (non-blocking) + while let Ok(frame) = frame_rx.try_recv() { + if !app.paused { + app.apply_frame(frame); + } + } + + // Poll for terminal events with timeout + if event::poll(tick_rate)? { + if let Event::Key(key) = event::read()? { + let action = match key.code { + KeyCode::Char('q') => TuiAction::Quit, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + TuiAction::Quit + } + KeyCode::Down | KeyCode::Char('j') => TuiAction::NextEntity, + KeyCode::Up | KeyCode::Char('k') => TuiAction::PrevEntity, + KeyCode::Enter => TuiAction::FocusDetail, + KeyCode::Esc => TuiAction::BackToList, + KeyCode::Right | KeyCode::Char('l') => TuiAction::HistoryForward, + KeyCode::Left | KeyCode::Char('h') => TuiAction::HistoryBack, + KeyCode::Home => TuiAction::HistoryOldest, + KeyCode::End => TuiAction::HistoryNewest, + KeyCode::Char('d') => TuiAction::ToggleDiff, + KeyCode::Char('r') => TuiAction::ToggleRaw, + KeyCode::Char('p') => TuiAction::TogglePause, + KeyCode::Char('/') => TuiAction::StartFilter, + KeyCode::Char('s') => TuiAction::SaveSnapshot, + _ => { + if app.filter_input_active { + match key.code { + KeyCode::Char(c) => TuiAction::FilterChar(c), + KeyCode::Backspace => TuiAction::FilterBackspace, + _ => continue, + } + } else { + continue; + } + } + }; + + if let TuiAction::Quit = action { + break; + } + app.handle_action(action); + } + } + } + + Ok(()) +} diff --git a/cli/src/commands/stream/tui/ui.rs b/cli/src/commands/stream/tui/ui.rs new file mode 100644 index 00000000..56042719 --- /dev/null +++ b/cli/src/commands/stream/tui/ui.rs @@ -0,0 +1,265 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, + Frame, +}; + +use super::app::{App, ViewMode}; + +pub fn draw(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Header + Constraint::Min(0), // Main content + Constraint::Length(1), // Timeline + Constraint::Length(1), // Status bar + ]) + .split(f.area()); + + draw_header(f, app, chunks[0]); + + match app.view_mode { + ViewMode::List => draw_split_view(f, app, chunks[1]), + ViewMode::Detail => draw_detail_view(f, app, chunks[1]), + } + + draw_timeline(f, app, chunks[2]); + draw_status_bar(f, app, chunks[3]); +} + +fn draw_header(f: &mut Frame, app: &App, area: Rect) { + let status = if app.paused { + Span::styled(" PAUSED ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)) + } else { + Span::styled(" LIVE ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)) + }; + + let header = Line::from(vec![ + Span::styled("hs stream ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::styled(&app.view, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + Span::raw(" "), + status, + Span::raw(" "), + Span::styled( + format!("Updates: {}", app.update_count), + Style::default().fg(Color::DarkGray), + ), + Span::raw(" "), + Span::styled( + format!("Entities: {}", app.entity_keys.len()), + Style::default().fg(Color::DarkGray), + ), + ]); + + f.render_widget(Paragraph::new(header), area); +} + +fn draw_split_view(f: &mut Frame, app: &App, area: Rect) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) + .split(area); + + draw_entity_list(f, app, chunks[0]); + draw_entity_detail(f, app, chunks[1]); +} + +fn draw_entity_list(f: &mut Frame, app: &App, area: Rect) { + let keys = app.filtered_keys(); + let items: Vec = keys + .iter() + .enumerate() + .map(|(i, key)| { + let style = if i == app.selected_index { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + let prefix = if i == app.selected_index { "> " } else { " " }; + ListItem::new(format!("{}{}", prefix, truncate_key(key, area.width as usize - 3))) + .style(style) + }) + .collect(); + + let title = if app.filter_input_active { + format!("Entities [/{}]", app.filter_text) + } else { + format!("Entities ({})", keys.len()) + }; + + let list = List::new(items).block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)), + ); + + f.render_widget(list, area); +} + +fn draw_entity_detail(f: &mut Frame, app: &App, area: Rect) { + let content = app.selected_entity_data().unwrap_or_else(|| { + if app.entity_keys.is_empty() { + "Waiting for data...".to_string() + } else { + "Select an entity".to_string() + } + }); + + let title = match app.selected_key() { + Some(key) => { + let mode = if app.show_diff { + " [diff]" + } else if app.history_position > 0 { + " [history]" + } else { + "" + }; + format!("{}{}", truncate_key(&key, area.width as usize - 10), mode) + } + None => "Detail".to_string(), + }; + + // Apply simple JSON syntax coloring + let lines: Vec = content + .lines() + .skip(app.scroll_offset as usize) + .map(|line| colorize_json_line(line)) + .collect(); + + let detail = Paragraph::new(lines) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .wrap(Wrap { trim: false }); + + f.render_widget(detail, area); +} + +fn draw_detail_view(f: &mut Frame, app: &App, area: Rect) { + draw_entity_detail(f, app, area); +} + +fn draw_timeline(f: &mut Frame, app: &App, area: Rect) { + let history_len = app.selected_history_len(); + let pos = app.history_position; + + let timeline = if history_len == 0 { + Line::from(vec![ + Span::styled(" History: ", Style::default().fg(Color::DarkGray)), + Span::styled("no data", Style::default().fg(Color::DarkGray)), + ]) + } else { + Line::from(vec![ + Span::styled(" History: ", Style::default().fg(Color::DarkGray)), + Span::styled("[|<] ", Style::default().fg(if pos < history_len - 1 { Color::White } else { Color::DarkGray })), + Span::styled("[<] ", Style::default().fg(if pos < history_len - 1 { Color::White } else { Color::DarkGray })), + Span::styled( + format!("update {}/{} ", history_len - pos, history_len), + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + ), + Span::styled("[>] ", Style::default().fg(if pos > 0 { Color::White } else { Color::DarkGray })), + Span::styled("[>|]", Style::default().fg(if pos > 0 { Color::White } else { Color::DarkGray })), + Span::raw(" "), + if app.show_diff { + Span::styled("[d]iff ON", Style::default().fg(Color::Green)) + } else { + Span::styled("[d]iff", Style::default().fg(Color::DarkGray)) + }, + ]) + }; + + f.render_widget(Paragraph::new(timeline), area); +} + +fn draw_status_bar(f: &mut Frame, app: &App, area: Rect) { + let status = Line::from(vec![ + Span::styled( + format!(" {} ", app.status()), + Style::default().fg(Color::DarkGray), + ), + Span::raw(" | "), + Span::styled("q", Style::default().fg(Color::Yellow)), + Span::styled("uit ", Style::default().fg(Color::DarkGray)), + Span::styled("p", Style::default().fg(Color::Yellow)), + Span::styled("ause ", Style::default().fg(Color::DarkGray)), + Span::styled("d", Style::default().fg(Color::Yellow)), + Span::styled("iff ", Style::default().fg(Color::DarkGray)), + Span::styled("r", Style::default().fg(Color::Yellow)), + Span::styled("aw ", Style::default().fg(Color::DarkGray)), + Span::styled("/", Style::default().fg(Color::Yellow)), + Span::styled("filter ", Style::default().fg(Color::DarkGray)), + Span::styled("s", Style::default().fg(Color::Yellow)), + Span::styled("ave ", Style::default().fg(Color::DarkGray)), + Span::styled("h/l", Style::default().fg(Color::Yellow)), + Span::styled(" history", Style::default().fg(Color::DarkGray)), + ]); + + f.render_widget(Paragraph::new(status), area); +} + +fn truncate_key(key: &str, max_len: usize) -> String { + if key.len() <= max_len { + key.to_string() + } else if max_len > 3 { + format!("{}...", &key[..max_len - 3]) + } else { + key[..max_len].to_string() + } +} + +fn colorize_json_line(line: &str) -> Line<'_> { + let trimmed = line.trim(); + + // Key-value lines + if trimmed.starts_with('"') { + if let Some(colon_pos) = trimmed.find("\":") { + let key_end = colon_pos + 1; + let indent = &line[..line.len() - trimmed.len()]; + let key = &trimmed[..key_end]; + let rest = &trimmed[key_end..]; + return Line::from(vec![ + Span::raw(indent), + Span::styled(key, Style::default().fg(Color::Cyan)), + colorize_value(rest), + ]); + } + } + + // String values (in arrays) + if (trimmed.starts_with('"') && trimmed.ends_with('"')) + || (trimmed.starts_with('"') && trimmed.ends_with("\",")) + { + return Line::from(Span::styled(line, Style::default().fg(Color::Green))); + } + + // Braces + if trimmed == "{" || trimmed == "}" || trimmed == "{}" || trimmed == "}," { + return Line::from(Span::styled(line, Style::default().fg(Color::DarkGray))); + } + + Line::from(Span::raw(line)) +} + +fn colorize_value(rest: &str) -> Span<'_> { + let trimmed = rest.trim().trim_end_matches(','); + if trimmed.starts_with('"') { + Span::styled(rest, Style::default().fg(Color::Green)) + } else if trimmed == "true" || trimmed == "false" { + Span::styled(rest, Style::default().fg(Color::Yellow)) + } else if trimmed == "null" { + Span::styled(rest, Style::default().fg(Color::DarkGray)) + } else if trimmed.parse::().is_ok() { + Span::styled(rest, Style::default().fg(Color::Magenta)) + } else { + Span::raw(rest) + } +} From 22329e9b15e1656e1a0d6a9ec69346322e1621cc Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:16:07 +0000 Subject: [PATCH 06/97] fix: properly handle subscribed frames on binary WebSocket channel The server sends subscribed acknowledgments as binary frames with a different shape (no `entity` field), causing parse_frame to fail. Now falls back to try_parse_subscribed_frame before warning, so real parse errors are still surfaced while subscribed frames are handled cleanly. Re-exports try_parse_subscribed_frame from hyperstack-sdk. --- cli/src/commands/stream/client.rs | 17 ++++++++++++++--- rust/hyperstack-sdk/src/lib.rs | 5 ++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 3b8ea22d..61132dcf 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -1,8 +1,8 @@ use anyhow::{Context, Result}; use futures_util::{SinkExt, StreamExt}; use hyperstack_sdk::{ - deep_merge_with_append, parse_frame, parse_snapshot_entities, ClientMessage, Frame, Operation, - Subscription, + deep_merge_with_append, parse_frame, parse_snapshot_entities, try_parse_subscribed_frame, + ClientMessage, Frame, Operation, Subscription, }; use std::collections::{HashMap, HashSet}; use tokio_tungstenite::{connect_async, tungstenite::Message}; @@ -135,11 +135,22 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { Some(Ok(Message::Binary(bytes))) => { match parse_frame(&bytes) { Ok(frame) => { + if frame.operation() == Operation::Subscribed { + eprintln!("Subscribed to {}", view); + continue; + } if process_frame(frame, view, &mut state)? { break; } } - Err(e) => eprintln!("Warning: failed to parse frame: {}", e), + Err(e) => { + // Check if it's a subscribed frame (different shape, no `entity` field) + if try_parse_subscribed_frame(&bytes).is_some() { + eprintln!("Subscribed to {}", view); + } else { + eprintln!("Warning: failed to parse binary frame: {}", e); + } + } } } Some(Ok(Message::Text(text))) => { diff --git a/rust/hyperstack-sdk/src/lib.rs b/rust/hyperstack-sdk/src/lib.rs index 28cb2d15..9eff09bb 100644 --- a/rust/hyperstack-sdk/src/lib.rs +++ b/rust/hyperstack-sdk/src/lib.rs @@ -33,7 +33,10 @@ pub use client::{HyperStack, HyperStackBuilder}; pub use connection::ConnectionState; pub use entity::Stack; pub use error::HyperStackError; -pub use frame::{parse_frame, parse_snapshot_entities, Frame, Mode, Operation, SnapshotEntity}; +pub use frame::{ + parse_frame, parse_snapshot_entities, try_parse_subscribed_frame, Frame, Mode, Operation, + SnapshotEntity, +}; pub use store::{deep_merge_with_append, SharedStore, StoreUpdate}; pub use stream::{ EntityStream, FilterMapStream, FilteredStream, KeyFilter, MapStream, RichEntityStream, From b82bcb0c70d44efcb049dad3f1fa52ac9911d414 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:27:05 +0000 Subject: [PATCH 07/97] fix: route all keypresses to filter input when filter mode is active Previously, keys like r, d, s, h, l etc. were matched as TUI commands before checking if filter input was active, making it impossible to type those characters in the filter. Now checks filter_input_active first and routes all Char keys to the filter text input. --- cli/src/commands/stream/tui/mod.rs | 56 ++++++++++++++++-------------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index 4248564c..d9cbc4ba 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -132,34 +132,38 @@ async fn run_loop( // Poll for terminal events with timeout if event::poll(tick_rate)? { if let Event::Key(key) = event::read()? { - let action = match key.code { - KeyCode::Char('q') => TuiAction::Quit, - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - TuiAction::Quit + // When filter input is active, capture all keys for typing + let action = if app.filter_input_active { + match key.code { + KeyCode::Esc => TuiAction::BackToList, + KeyCode::Enter => TuiAction::BackToList, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + TuiAction::Quit + } + KeyCode::Char(c) => TuiAction::FilterChar(c), + KeyCode::Backspace => TuiAction::FilterBackspace, + _ => continue, } - KeyCode::Down | KeyCode::Char('j') => TuiAction::NextEntity, - KeyCode::Up | KeyCode::Char('k') => TuiAction::PrevEntity, - KeyCode::Enter => TuiAction::FocusDetail, - KeyCode::Esc => TuiAction::BackToList, - KeyCode::Right | KeyCode::Char('l') => TuiAction::HistoryForward, - KeyCode::Left | KeyCode::Char('h') => TuiAction::HistoryBack, - KeyCode::Home => TuiAction::HistoryOldest, - KeyCode::End => TuiAction::HistoryNewest, - KeyCode::Char('d') => TuiAction::ToggleDiff, - KeyCode::Char('r') => TuiAction::ToggleRaw, - KeyCode::Char('p') => TuiAction::TogglePause, - KeyCode::Char('/') => TuiAction::StartFilter, - KeyCode::Char('s') => TuiAction::SaveSnapshot, - _ => { - if app.filter_input_active { - match key.code { - KeyCode::Char(c) => TuiAction::FilterChar(c), - KeyCode::Backspace => TuiAction::FilterBackspace, - _ => continue, - } - } else { - continue; + } else { + match key.code { + KeyCode::Char('q') => TuiAction::Quit, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + TuiAction::Quit } + KeyCode::Down | KeyCode::Char('j') => TuiAction::NextEntity, + KeyCode::Up | KeyCode::Char('k') => TuiAction::PrevEntity, + KeyCode::Enter => TuiAction::FocusDetail, + KeyCode::Esc => TuiAction::BackToList, + KeyCode::Right | KeyCode::Char('l') => TuiAction::HistoryForward, + KeyCode::Left | KeyCode::Char('h') => TuiAction::HistoryBack, + KeyCode::Home => TuiAction::HistoryOldest, + KeyCode::End => TuiAction::HistoryNewest, + KeyCode::Char('d') => TuiAction::ToggleDiff, + KeyCode::Char('r') => TuiAction::ToggleRaw, + KeyCode::Char('p') => TuiAction::TogglePause, + KeyCode::Char('/') => TuiAction::StartFilter, + KeyCode::Char('s') => TuiAction::SaveSnapshot, + _ => continue, } }; From c4f1d0a6e3be11b58f08b1541727808e02952ca8 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:31:32 +0000 Subject: [PATCH 08/97] feat: TUI filter searches across all entity data values, not just keys The / filter now recursively searches all string, number, and boolean values in each entity's JSON data. Typing "test" matches any entity where any field value contains "test" (case-insensitive), not just entities whose key contains the search term. --- cli/src/commands/stream/tui/app.rs | 32 +++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index b397ff67..50446608 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -1,4 +1,5 @@ use hyperstack_sdk::{parse_snapshot_entities, Frame, Operation}; +use serde_json::Value; use crate::commands::stream::snapshot::SnapshotRecorder; use crate::commands::stream::store::EntityStore; @@ -276,9 +277,38 @@ impl App { let lower = self.filter_text.to_lowercase(); self.entity_keys .iter() - .filter(|k| k.to_lowercase().contains(&lower)) + .filter(|k| { + // Match on key itself + if k.to_lowercase().contains(&lower) { + return true; + } + // Match on any value inside the entity data + if let Some(record) = self.store.get(k) { + return value_contains_str(&record.current, &lower); + } + false + }) .map(|s| s.as_str()) .collect() } } } + +/// Recursively search all values in a JSON tree for a substring match. +fn value_contains_str(value: &Value, needle: &str) -> bool { + match value { + Value::String(s) => s.to_lowercase().contains(needle), + Value::Number(n) => n.to_string().contains(needle), + Value::Bool(b) => { + let s = if *b { "true" } else { "false" }; + s.contains(needle) + } + Value::Object(map) => { + map.values().any(|v| value_contains_str(v, needle)) + } + Value::Array(arr) => { + arr.iter().any(|v| value_contains_str(v, needle)) + } + Value::Null => false, + } +} From c1737b7f8bea9eb9d39c0a133aa1865897ab4cff Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:35:21 +0000 Subject: [PATCH 09/97] fix: clamp selected index to filtered list when filter text changes When typing a filter that reduces the entity list, the selection now clamps to stay within the filtered results. Navigation (j/k) also bounds against the filtered count instead of the full entity list. --- cli/src/commands/stream/tui/app.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index 50446608..238f6b85 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -142,8 +142,9 @@ impl App { match action { TuiAction::Quit => {} TuiAction::NextEntity => { - if !self.entity_keys.is_empty() { - self.selected_index = (self.selected_index + 1).min(self.entity_keys.len() - 1); + let count = self.filtered_keys().len(); + if count > 0 { + self.selected_index = (self.selected_index + 1).min(count - 1); self.history_position = 0; self.scroll_offset = 0; } @@ -220,15 +221,29 @@ impl App { } TuiAction::FilterChar(c) => { self.filter_text.push(c); + self.clamp_selection(); } TuiAction::FilterBackspace => { self.filter_text.pop(); + self.clamp_selection(); } } } + fn clamp_selection(&mut self) { + let count = self.filtered_keys().len(); + if count == 0 { + self.selected_index = 0; + } else if self.selected_index >= count { + self.selected_index = count - 1; + } + self.history_position = 0; + self.scroll_offset = 0; + } + pub fn selected_key(&self) -> Option { - self.entity_keys.get(self.selected_index).cloned() + let keys = self.filtered_keys(); + keys.get(self.selected_index).map(|s| s.to_string()) } pub fn selected_entity_data(&self) -> Option { From bcf73d72391191b133a22c5ea5667960e45c722a Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:38:54 +0000 Subject: [PATCH 10/97] fix: auto-scroll entity list to keep selected item visible Uses ratatui's ListState with the selected index so the list widget automatically scrolls to keep the highlighted entity in view when navigating past the visible area. Also shows filter text and filtered/total count in the title when a filter is active. --- cli/src/commands/stream/tui/ui.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/cli/src/commands/stream/tui/ui.rs b/cli/src/commands/stream/tui/ui.rs index 56042719..b9945e0f 100644 --- a/cli/src/commands/stream/tui/ui.rs +++ b/cli/src/commands/stream/tui/ui.rs @@ -2,7 +2,7 @@ use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, Frame, }; @@ -88,18 +88,25 @@ fn draw_entity_list(f: &mut Frame, app: &App, area: Rect) { let title = if app.filter_input_active { format!("Entities [/{}]", app.filter_text) + } else if !app.filter_text.is_empty() { + format!("Entities ({}/{}) [/{}]", keys.len(), app.entity_keys.len(), app.filter_text) } else { format!("Entities ({})", keys.len()) }; - let list = List::new(items).block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)), - ); + let list = List::new(items) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)), + ) + .highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); + + let mut list_state = ListState::default(); + list_state.select(Some(app.selected_index)); - f.render_widget(list, area); + f.render_stateful_widget(list, area, &mut list_state); } fn draw_entity_detail(f: &mut Frame, app: &App, area: Rect) { From 85558b3731a7fd062bbec6974156727d7b5560a9 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:50:49 +0000 Subject: [PATCH 11/97] feat: add vim motion commands to TUI - gg: jump to top of list - G: jump to bottom of list - Ctrl+d / Ctrl+u: half-page down/up - n: jump to next filter match (wraps around) - Number prefixes: e.g. 10j moves down 10, 5k moves up 5, 3Ctrl+d moves 3 half-pages down - Esc clears any pending count/g prefix --- cli/src/commands/stream/tui/app.rs | 77 +++++++++++++++++++++++++++++- cli/src/commands/stream/tui/mod.rs | 53 ++++++++++++++++++-- 2 files changed, 125 insertions(+), 5 deletions(-) diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index 238f6b85..10f8022f 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -23,6 +23,12 @@ pub enum TuiAction { SaveSnapshot, FilterChar(char), FilterBackspace, + // Vim motions + GotoTop, + GotoBottom, + HalfPageDown, + HalfPageUp, + NextMatch, } #[derive(Clone, Copy, PartialEq)] @@ -48,6 +54,9 @@ pub struct App { pub status_time: std::time::Instant, pub update_count: u64, pub scroll_offset: u16, + pub visible_rows: usize, + pub pending_count: Option, + pub pending_g: bool, store: EntityStore, raw_frames: Vec, recorder: Option, @@ -71,6 +80,9 @@ impl App { status_time: std::time::Instant::now(), update_count: 0, scroll_offset: 0, + visible_rows: 30, + pending_count: None, + pending_g: false, store: EntityStore::new(), raw_frames: Vec::new(), recorder: Some(SnapshotRecorder::new(&view, &url)), @@ -138,19 +150,35 @@ impl App { } } + /// Take and reset the pending count prefix (e.g. "10j" → 10). Returns 1 if no count. + fn take_count(&mut self) -> usize { + let n = self.pending_count.unwrap_or(1); + self.pending_count = None; + self.pending_g = false; + n + } + pub fn handle_action(&mut self, action: TuiAction) { + // Reset pending_g for any action that isn't GotoTop (gg handled in mod.rs) + match &action { + TuiAction::GotoTop => {} + _ => { self.pending_g = false; } + } + match action { TuiAction::Quit => {} TuiAction::NextEntity => { + let n = self.take_count(); let count = self.filtered_keys().len(); if count > 0 { - self.selected_index = (self.selected_index + 1).min(count - 1); + self.selected_index = (self.selected_index + n).min(count - 1); self.history_position = 0; self.scroll_offset = 0; } } TuiAction::PrevEntity => { - self.selected_index = self.selected_index.saturating_sub(1); + let n = self.take_count(); + self.selected_index = self.selected_index.saturating_sub(n); self.history_position = 0; self.scroll_offset = 0; } @@ -227,6 +255,51 @@ impl App { self.filter_text.pop(); self.clamp_selection(); } + TuiAction::GotoTop => { + self.pending_count = None; + self.selected_index = 0; + self.history_position = 0; + self.scroll_offset = 0; + } + TuiAction::GotoBottom => { + self.pending_count = None; + let count = self.filtered_keys().len(); + if count > 0 { + self.selected_index = count - 1; + } + self.history_position = 0; + self.scroll_offset = 0; + } + TuiAction::HalfPageDown => { + let n = self.take_count(); + let half = self.visible_rows / 2; + let count = self.filtered_keys().len(); + if count > 0 { + self.selected_index = (self.selected_index + half * n).min(count - 1); + } + self.history_position = 0; + self.scroll_offset = 0; + } + TuiAction::HalfPageUp => { + let n = self.take_count(); + let half = self.visible_rows / 2; + self.selected_index = self.selected_index.saturating_sub(half * n); + self.history_position = 0; + self.scroll_offset = 0; + } + TuiAction::NextMatch => { + if self.filter_text.is_empty() { + return; + } + let n = self.take_count(); + let keys = self.filtered_keys(); + let count = keys.len(); + if count > 0 { + self.selected_index = (self.selected_index + n) % count; + } + self.history_position = 0; + self.scroll_offset = 0; + } } } diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index d9cbc4ba..4e2abbbf 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -120,6 +120,10 @@ async fn run_loop( tick_rate: std::time::Duration, ) -> Result<()> { loop { + // Update visible rows from terminal size (minus header/timeline/status/borders) + let term_size = terminal.size()?; + app.visible_rows = term_size.height.saturating_sub(6) as usize; + terminal.draw(|f| ui::draw(f, app))?; // Drain available frames (non-blocking) @@ -145,6 +149,18 @@ async fn run_loop( _ => continue, } } else { + // Number prefix accumulation (vim count) + if let KeyCode::Char(c @ '0'..='9') = key.code { + // Don't treat '0' as count start (could be "go to beginning" in future) + if c != '0' || app.pending_count.is_some() { + let digit = c as usize - '0' as usize; + let current = app.pending_count.unwrap_or(0); + app.pending_count = Some(current * 10 + digit); + app.pending_g = false; + continue; + } + } + match key.code { KeyCode::Char('q') => TuiAction::Quit, KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -152,10 +168,37 @@ async fn run_loop( } KeyCode::Down | KeyCode::Char('j') => TuiAction::NextEntity, KeyCode::Up | KeyCode::Char('k') => TuiAction::PrevEntity, + KeyCode::Char('G') => TuiAction::GotoBottom, + KeyCode::Char('g') => { + if app.pending_g { + // gg = go to top + TuiAction::GotoTop + } else { + app.pending_g = true; + continue; + } + } + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + TuiAction::HalfPageDown + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + TuiAction::HalfPageUp + } + KeyCode::Char('n') => TuiAction::NextMatch, KeyCode::Enter => TuiAction::FocusDetail, - KeyCode::Esc => TuiAction::BackToList, + KeyCode::Esc => { + app.pending_count = None; + app.pending_g = false; + TuiAction::BackToList + } KeyCode::Right | KeyCode::Char('l') => TuiAction::HistoryForward, - KeyCode::Left | KeyCode::Char('h') => TuiAction::HistoryBack, + KeyCode::Left | KeyCode::Char('h') => { + if app.pending_g { + app.pending_g = false; + continue; + } + TuiAction::HistoryBack + } KeyCode::Home => TuiAction::HistoryOldest, KeyCode::End => TuiAction::HistoryNewest, KeyCode::Char('d') => TuiAction::ToggleDiff, @@ -163,7 +206,11 @@ async fn run_loop( KeyCode::Char('p') => TuiAction::TogglePause, KeyCode::Char('/') => TuiAction::StartFilter, KeyCode::Char('s') => TuiAction::SaveSnapshot, - _ => continue, + _ => { + app.pending_count = None; + app.pending_g = false; + continue; + } } }; From 0aa7f0508196e75b7327124b799e6fd5ca299418 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:55:41 +0000 Subject: [PATCH 12/97] fix: persist ListState so scrolling works in both directions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ListState was being recreated fresh each frame, losing the scroll offset. Now stored in App and synced with selected_index on every action, so ratatui properly auto-scrolls the entity list in both directions — pressing k after scrolling past the bottom now scrolls back up as expected. --- cli/src/commands/stream/tui/app.rs | 5 +++++ cli/src/commands/stream/tui/ui.rs | 13 +++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index 10f8022f..7ed2c449 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -1,4 +1,5 @@ use hyperstack_sdk::{parse_snapshot_entities, Frame, Operation}; +use ratatui::widgets::ListState; use serde_json::Value; use crate::commands::stream::snapshot::SnapshotRecorder; @@ -57,6 +58,7 @@ pub struct App { pub visible_rows: usize, pub pending_count: Option, pub pending_g: bool, + pub list_state: ListState, store: EntityStore, raw_frames: Vec, recorder: Option, @@ -83,6 +85,7 @@ impl App { visible_rows: 30, pending_count: None, pending_g: false, + list_state: ListState::default().with_selected(Some(0)), store: EntityStore::new(), raw_frames: Vec::new(), recorder: Some(SnapshotRecorder::new(&view, &url)), @@ -301,6 +304,7 @@ impl App { self.scroll_offset = 0; } } + self.list_state.select(Some(self.selected_index)); } fn clamp_selection(&mut self) { @@ -312,6 +316,7 @@ impl App { } self.history_position = 0; self.scroll_offset = 0; + self.list_state.select(Some(self.selected_index)); } pub fn selected_key(&self) -> Option { diff --git a/cli/src/commands/stream/tui/ui.rs b/cli/src/commands/stream/tui/ui.rs index b9945e0f..2cd4e71a 100644 --- a/cli/src/commands/stream/tui/ui.rs +++ b/cli/src/commands/stream/tui/ui.rs @@ -2,13 +2,13 @@ use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, + widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, Frame, }; use super::app::{App, ViewMode}; -pub fn draw(f: &mut Frame, app: &App) { +pub fn draw(f: &mut Frame, app: &mut App) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -57,7 +57,7 @@ fn draw_header(f: &mut Frame, app: &App, area: Rect) { f.render_widget(Paragraph::new(header), area); } -fn draw_split_view(f: &mut Frame, app: &App, area: Rect) { +fn draw_split_view(f: &mut Frame, app: &mut App, area: Rect) { let chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) @@ -67,7 +67,7 @@ fn draw_split_view(f: &mut Frame, app: &App, area: Rect) { draw_entity_detail(f, app, chunks[1]); } -fn draw_entity_list(f: &mut Frame, app: &App, area: Rect) { +fn draw_entity_list(f: &mut Frame, app: &mut App, area: Rect) { let keys = app.filtered_keys(); let items: Vec = keys .iter() @@ -103,10 +103,7 @@ fn draw_entity_list(f: &mut Frame, app: &App, area: Rect) { ) .highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); - let mut list_state = ListState::default(); - list_state.select(Some(app.selected_index)); - - f.render_stateful_widget(list, area, &mut list_state); + f.render_stateful_widget(list, area, &mut app.list_state); } fn draw_entity_detail(f: &mut Frame, app: &App, area: Rect) { From 785265991d16ecf1b8dc0ed423e005452672bc23 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:01:53 +0000 Subject: [PATCH 13/97] fix: disambiguate list position from entity version in timeline bar The timeline bar now shows two distinct pieces of info: - Row position: "Row 1/513" (your position in the entity list) - Entity version: "version 1/2" (update history for the selected entity) Previously only showed "update N/N" which was confusing because it looked like it referred to list position rather than entity history. --- cli/src/commands/stream/tui/ui.rs | 52 +++++++++++++++++-------------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/cli/src/commands/stream/tui/ui.rs b/cli/src/commands/stream/tui/ui.rs index 2cd4e71a..9f3e5b71 100644 --- a/cli/src/commands/stream/tui/ui.rs +++ b/cli/src/commands/stream/tui/ui.rs @@ -155,33 +155,37 @@ fn draw_detail_view(f: &mut Frame, app: &App, area: Rect) { fn draw_timeline(f: &mut Frame, app: &App, area: Rect) { let history_len = app.selected_history_len(); let pos = app.history_position; + let list_len = app.filtered_keys().len(); + let list_pos = if list_len > 0 { app.selected_index + 1 } else { 0 }; - let timeline = if history_len == 0 { - Line::from(vec![ - Span::styled(" History: ", Style::default().fg(Color::DarkGray)), - Span::styled("no data", Style::default().fg(Color::DarkGray)), - ]) + let mut spans = vec![ + Span::styled( + format!(" Row {}/{}", list_pos, list_len), + Style::default().fg(Color::DarkGray), + ), + Span::styled(" │ ", Style::default().fg(Color::DarkGray)), + ]; + + if history_len == 0 { + spans.push(Span::styled("Entity history: no data", Style::default().fg(Color::DarkGray))); } else { - Line::from(vec![ - Span::styled(" History: ", Style::default().fg(Color::DarkGray)), - Span::styled("[|<] ", Style::default().fg(if pos < history_len - 1 { Color::White } else { Color::DarkGray })), - Span::styled("[<] ", Style::default().fg(if pos < history_len - 1 { Color::White } else { Color::DarkGray })), - Span::styled( - format!("update {}/{} ", history_len - pos, history_len), - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), - ), - Span::styled("[>] ", Style::default().fg(if pos > 0 { Color::White } else { Color::DarkGray })), - Span::styled("[>|]", Style::default().fg(if pos > 0 { Color::White } else { Color::DarkGray })), - Span::raw(" "), - if app.show_diff { - Span::styled("[d]iff ON", Style::default().fg(Color::Green)) - } else { - Span::styled("[d]iff", Style::default().fg(Color::DarkGray)) - }, - ]) - }; + spans.push(Span::styled("[|<] ", Style::default().fg(if pos < history_len - 1 { Color::White } else { Color::DarkGray }))); + spans.push(Span::styled("[<] ", Style::default().fg(if pos < history_len - 1 { Color::White } else { Color::DarkGray }))); + spans.push(Span::styled( + format!("version {}/{} ", history_len - pos, history_len), + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled("[>] ", Style::default().fg(if pos > 0 { Color::White } else { Color::DarkGray }))); + spans.push(Span::styled("[>|]", Style::default().fg(if pos > 0 { Color::White } else { Color::DarkGray }))); + spans.push(Span::raw(" ")); + spans.push(if app.show_diff { + Span::styled("[d]iff ON", Style::default().fg(Color::Green)) + } else { + Span::styled("[d]iff", Style::default().fg(Color::DarkGray)) + }); + } - f.render_widget(Paragraph::new(timeline), area); + f.render_widget(Paragraph::new(Line::from(spans)), area); } fn draw_status_bar(f: &mut Frame, app: &App, area: Rect) { From fa78016c6276e14634988e267a25c18f9a5891a4 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:06:51 +0000 Subject: [PATCH 14/97] fix: remove duplicate entity count from header The entity count is already shown in the list panel title, no need to repeat it in the header bar. --- cli/src/commands/stream/tui/ui.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cli/src/commands/stream/tui/ui.rs b/cli/src/commands/stream/tui/ui.rs index 9f3e5b71..bab2eaa8 100644 --- a/cli/src/commands/stream/tui/ui.rs +++ b/cli/src/commands/stream/tui/ui.rs @@ -47,11 +47,6 @@ fn draw_header(f: &mut Frame, app: &App, area: Rect) { format!("Updates: {}", app.update_count), Style::default().fg(Color::DarkGray), ), - Span::raw(" "), - Span::styled( - format!("Entities: {}", app.entity_keys.len()), - Style::default().fg(Color::DarkGray), - ), ]); f.render_widget(Paragraph::new(header), area); From 9dfdbca54804aa41f9b3a9d8127b4739ad098e2f Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:13:14 +0000 Subject: [PATCH 15/97] chore: Remove plan --- PLAN-hs-stream.md | 312 ---------------------------------------------- 1 file changed, 312 deletions(-) delete mode 100644 PLAN-hs-stream.md diff --git a/PLAN-hs-stream.md b/PLAN-hs-stream.md deleted file mode 100644 index a329dde9..00000000 --- a/PLAN-hs-stream.md +++ /dev/null @@ -1,312 +0,0 @@ -# Plan: `hs stream` — Live WebSocket Stream CLI + TUI - -## Context - -Users can deploy stacks with `hs up` but have no CLI way to observe live stream data without writing code. This is the highest-leverage missing UX feature. The goal is a single `hs stream` command that: - -1. Connects to a deployed stack's WebSocket and streams entity data to stdout (pipe-friendly NDJSON) -2. Supports rich filtering, `--first` triggers, raw vs merged output, NO_DNA agent format -3. Provides an interactive TUI for exploring entities, time-traveling through updates -4. Ensures every TUI action has a non-interactive CLI equivalent for agent consumption -5. Supports saving/loading snapshot recordings - -## Key Existing Infrastructure - -| Component | Location | Reuse | -|-----------|----------|-------| -| CLI entry point | [main.rs](cli/src/main.rs) | Add `Stream` variant to `Commands` enum | -| Config with WS URLs | [config.rs](cli/src/config.rs) `StackConfig.url` | Resolve WebSocket URL from `hyperstack.toml` | -| SDK Frame types | [frame.rs](rust/hyperstack-sdk/src/frame.rs) | `Frame`, `parse_frame`, `parse_snapshot_entities`, gzip handling | -| SDK Subscription | [subscription.rs](rust/hyperstack-sdk/src/subscription.rs) | `ClientMessage`, `Subscription` types | -| SDK Connection | [connection.rs](rust/hyperstack-sdk/src/connection.rs) | Pattern reference for WS connect + reconnect loop | -| SDK Store + merge | [store.rs](rust/hyperstack-sdk/src/store.rs) | `deep_merge_with_append` (currently private — needs `pub`) | -| SDK SharedStore | [store.rs](rust/hyperstack-sdk/src/store.rs) | `StoreUpdate` type with `previous`/`patch` fields | - -## Command Design - -``` -hs stream [OPTIONS] - -ARGS: - Entity/view to subscribe: EntityName/mode (e.g. OreRound/latest) - -CONNECTION: - --url WebSocket URL override - --stack Stack name (resolves URL from hyperstack.toml) - --key Entity key for state-mode subscriptions - -OUTPUT MODE (mutually exclusive): - --raw Raw WebSocket frames (no merge) - --no-dna NO_DNA agent-friendly envelope format - [default] Merged entity NDJSON - -FILTERING: - --where Filter: field=value, field>N, field~regex (repeatable, ANDed) - --select Project specific fields (comma-separated dot paths) - --first Exit after first entity matches filter - --ops Filter by operation type: upsert,patch,delete - -SUBSCRIPTION: - --take Max entities in snapshot - --skip Skip N entities - --no-snapshot Skip initial snapshot - --after Resume from cursor (seq value) - -RECORDING: - --save Record frames to JSON file - --duration Auto-stop recording after N seconds - --load Replay a saved recording (no WS connection) - -TUI: - --tui Interactive terminal UI - -HISTORY (non-interactive agent equivalents): - --history Show update history for --key entity - --at Show entity at history index (0=latest) - --diff Show diff between consecutive updates - --count Show running count of updates only -``` - -### URL Resolution Priority -1. `--url wss://...` — explicit -2. `--stack my-stack` — lookup `StackConfig.url` via `config.find_stack()` -3. Auto-match entity name from view to a stack in config -4. Error with list of available stacks - -### NO_DNA Format (`--no-dna`) -When `NO_DNA` env var is set OR `--no-dna` flag is used: -- Each line is a JSON envelope: `{"schema":"no-dna/v1", "tool":"hs-stream", "action":"entity_update"|"connected"|"snapshot_complete"|"error", "data":{...}, "meta":{update_count, entities_tracked, connected}}` -- No spinners, no color, no interactive prompts -- Lifecycle events (connected, snapshot_complete, disconnected) emitted as structured events - -### Filter DSL (`--where`) -``` -field=value exact match (string or number auto-coercion) -field!=value not equal -field>N greater than (numeric) -field>=N greater or equal -field` for patch merging - -**Store path** (`--tui`, `--history`, `--save`): -``` -connect_async(url) → subscribe → frame loop → EntityStore → [filter] → output/TUI -``` -- `EntityStore` tracks full entity state + history ring buffer per entity -- History capped at configurable max (default 1000 entries per entity) - -### EntityStore (new, in CLI) -```rust -struct EntityStore { - entities: HashMap, - max_history: usize, -} -struct EntityRecord { - current: Value, - history: VecDeque, // ring buffer -} -struct HistoryEntry { - timestamp: DateTime, - seq: Option, - op: Operation, - state: Value, // full entity after this update - patch: Option, // raw patch for patch ops -} -``` - -### TUI Architecture -Uses `ratatui` + `crossterm` (behind `tui` feature flag). - -**Layout:** -``` -┌──────────────────────────────────────────────────────┐ -│ hs stream OreRound/latest [connected] │ -├─────────────────┬────────────────────────────────────┤ -│ Entities │ Entity Detail │ -│ │ │ -│ > round_42 │ { │ -│ round_43 │ "roundId": 42, │ -│ round_44 │ "rewards": "1.5 SOL", │ -│ │ ... │ -│ │ } │ -├─────────────────┴────────────────────────────────────┤ -│ History: [|<] [<] update 3/7 [>] [>|] │ -├──────────────────────────────────────────────────────┤ -│ Filter: --where roundId>40 Updates: 127 │ -└──────────────────────────────────────────────────────┘ -``` - -**Key bindings:** -- `j/k`/arrows: navigate entity list -- `Enter`: focus detail (full width), `Esc`: back -- `h/l`/left/right: step through history (time travel) -- `Home/End`: oldest/newest history -- `d`: toggle diff view -- `/`: type filter expression -- `r`: toggle raw/merged -- `s`: save snapshot -- `p`: pause/resume -- `q`: quit - -**Event loop:** `tokio::select!` over crossterm events (16ms tick) + WS frame channel (`mpsc`). - -### TUI ↔ Agent Equivalence Table - -| TUI Action | Agent CLI | -|---|---| -| Browse entity list | `hs stream View/list` (prints all entities) | -| Select entity by key | `hs stream View/mode --key ` | -| View detail | Default merged output | -| Time travel to step N | `--history --at N --key ` | -| Show diff | `--diff --key ` | -| Filter | `--where "field=value"` | -| Raw frames | `--raw` | -| Save dataset | `--save file.json --duration 30` | -| Load replay | `--load file.json` | -| Count updates | `--count` | -| First match | `--first --where "field=value"` | - -### Snapshot File Format -```json -{ - "version": 1, - "view": "OreRound/latest", - "url": "wss://...", - "captured_at": "2026-03-23T10:00:00Z", - "duration_ms": 30000, - "frame_count": 147, - "frames": [ - {"ts": 1711180800000, "frame": {"mode": "list", "entity": "OreRound/latest", "op": "upsert", "...": "..."}}, - "..." - ] -} -``` -- `--load file.json` replays through the same merge/filter/output pipeline -- `--load file.json --tui` enables TUI replay with time travel - -## File Structure - -``` -cli/src/ - main.rs # Add Stream to Commands enum - commands/ - mod.rs # Add pub mod stream - stream/ - mod.rs # Entry point, URL resolution, tokio runtime, dispatch - client.rs # WebSocket connect, subscribe, frame loop (reuses SDK types) - store.rs # EntityStore with history + deep_merge_with_append - filter.rs # --where DSL parser and evaluator - output.rs # Formatters: ndjson, no_dna, raw - snapshot.rs # --save/--load file I/O and replay - tui/ - mod.rs # TuiApp state + main event loop - ui.rs # ratatui layout rendering - widgets.rs # Entity list, JSON viewer, timeline bar -``` - -## Dependencies to Add (cli/Cargo.toml) - -```toml -# Async (only used by stream command) -tokio = { version = "1.0", features = ["rt-multi-thread", "sync", "time", "macros", "signal"] } -futures-util = { version = "0.3", features = ["sink"] } -tokio-tungstenite = { version = "0.21", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } - -# Reuse SDK frame/subscription types -hyperstack-sdk = { path = "../rust/hyperstack-sdk", version = "0.5.10" } - -# TUI (behind feature flag) -ratatui = { version = "0.29", optional = true } -crossterm = { version = "0.28", optional = true } - -[features] -tui = ["ratatui", "crossterm"] -``` - -### SDK Change Required -Make `deep_merge_with_append` public in [store.rs](rust/hyperstack-sdk/src/store.rs:119): -```rust -pub fn deep_merge_with_append(...) // was fn (private) -``` - -## Implementation Phases - -### Phase 1: Core Streaming (MVP) -1. Add `Stream` variant to `Commands` enum in `main.rs` -2. Create `commands/stream/mod.rs` — arg parsing, URL resolution, tokio runtime entry -3. Implement `client.rs` — direct WS connection using `tokio-tungstenite`, subscribe, frame receive loop -4. Implement `output.rs` — NDJSON line output to stdout -5. Wire `--raw` mode (frame → JSON line → stdout) -6. Wire merged mode with inline `HashMap` + `deep_merge_with_append` -7. Make `deep_merge_with_append` pub in SDK - -### Phase 2: Filtering + Flags -8. Implement `filter.rs` — parse `--where` expressions, evaluate against `serde_json::Value` -9. Wire `--first` (disconnect + exit 0 after first filter match) -10. Wire `--select` (project fields from output) -11. Wire `--ops` (filter by operation type) -12. Implement `--no-dna` output envelope format -13. Wire `--count` mode - -### Phase 3: Recording -14. Implement `snapshot.rs` — `--save` appends timestamped frames to buffer, writes on Ctrl+C or `--duration` -15. Implement `--load` replay through same pipeline - -### Phase 4: History + Store -16. Implement `store.rs` — `EntityStore` with history ring buffer -17. Wire `--history`, `--at`, `--diff` flags for non-interactive history access - -### Phase 5: TUI -18. Add `ratatui`/`crossterm` behind `tui` feature flag -19. Implement `tui/mod.rs` — `TuiApp` state, `select!` event loop -20. Implement `tui/ui.rs` — three-panel layout -21. Implement `tui/widgets.rs` — entity list, JSON viewer with syntax highlighting, timeline bar -22. Wire entity navigation, detail view, history stepping, diff view, filter input, pause/resume, save - -## Verification - -### Unit Tests -- `filter.rs`: All operators, nested paths, type coercion, null handling -- `store.rs`: Merge behavior, history ring buffer, eviction -- `output.rs`: NDJSON format, NO_DNA envelope, field projection -- `snapshot.rs`: Save/load round-trip, replay ordering - -### Integration Tests -- Mock WS server (tokio-tungstenite server in test) sends scripted frame sequence -- Verify `--raw` outputs valid NDJSON -- Verify merged mode correctly patches entities -- Verify `--where` excludes non-matching -- Verify `--first` exits after match -- Verify `--save`/`--load` round-trip - -### Manual E2E -- `hs stream OreRound/latest --stack ore` against a live deployment -- Pipe to `jq '.rewards'` — verify valid JSON per line -- `hs stream OreRound/latest --raw | head -5` — verify `head` causes clean exit -- `hs stream OreRound/latest --tui` — interactive exploration -- `hs stream OreRound/latest --save test.json --duration 10 && hs stream --load test.json --tui` From c86869e5a2e456496d524bc334121ce3200d7cbe Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:40:21 +0000 Subject: [PATCH 16/97] fix: move --duration deadline into tokio::select! arm for precise timing Previously the deadline was checked at the top of the loop before entering select!, so on quiet streams the actual stop time could overshoot by up to the 30-second ping interval. Now uses a sleep future as a select! arm that fires exactly when the duration expires. --- cli/src/commands/stream/client.rs | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 61132dcf..10d6dd4e 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -109,10 +109,15 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { // Ping interval let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(30)); - // Duration timer for --save --duration - let duration_deadline = args.duration.map(|secs| { - tokio::time::Instant::now() + std::time::Duration::from_secs(secs) - }); + // Duration timer for --save --duration (as a select! arm for precise timing) + let duration_future = async { + if let Some(secs) = args.duration { + tokio::time::sleep(std::time::Duration::from_secs(secs)).await; + } else { + std::future::pending::<()>().await; + } + }; + tokio::pin!(duration_future); // Handle Ctrl+C let shutdown = tokio::signal::ctrl_c(); @@ -121,14 +126,6 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { let mut snapshot_complete = false; loop { - // Check duration deadline - if let Some(deadline) = duration_deadline { - if tokio::time::Instant::now() >= deadline { - eprintln!("Duration reached, stopping..."); - break; - } - } - tokio::select! { msg = ws_rx.next() => { match msg { @@ -203,6 +200,10 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { let _ = ws_tx.send(Message::Text(msg)).await; } } + _ = &mut duration_future => { + eprintln!("Duration reached, stopping..."); + break; + } _ = &mut shutdown => { eprintln!("\nDisconnecting..."); let _ = ws_tx.close().await; From 1e775ba30a6d63a7fc6b96bdf1d53c4412ef3038 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:42:04 +0000 Subject: [PATCH 17/97] fix: use full dot-path as key in --select to prevent data loss on collisions Previously --select "a.id,b.id" would output {"id": }, silently overwriting a.id. Now uses the full dot-path as the output key: {"a.id": 1, "b.id": 2}. Single-segment paths are unchanged (--select "name" still outputs {"name": ...}). Adds test for the collision case. --- cli/src/commands/stream/filter.rs | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/cli/src/commands/stream/filter.rs b/cli/src/commands/stream/filter.rs index 6f3f256b..cc17f51d 100644 --- a/cli/src/commands/stream/filter.rs +++ b/cli/src/commands/stream/filter.rs @@ -215,15 +215,18 @@ fn make_not_regex(value: &str) -> Result { /// Project specific fields from a JSON value. /// Returns a new object with only the selected dot-paths. +/// Uses full dot-path as key to avoid collisions (e.g. "a.id" and "b.id" +/// produce {"a.id": ..., "b.id": ...} instead of silently overwriting). pub fn select_fields(value: &Value, fields: &[Vec]) -> Value { let mut result = serde_json::Map::new(); for path in fields { if let Some(v) = resolve_path(value, path) { - // Use the leaf field name as the key in flat projection - let key = path.last().map(|s| s.as_str()).unwrap_or(""); - if !key.is_empty() { - result.insert(key.to_string(), v.clone()); - } + let key = if path.len() == 1 { + path[0].clone() + } else { + path.join(".") + }; + result.insert(key, v.clone()); } } Value::Object(result) @@ -308,6 +311,14 @@ mod tests { let v = json!({"name": "alice", "age": 30, "nested": {"x": 1}}); let fields = parse_select("name,nested.x"); let result = select_fields(&v, &fields); - assert_eq!(result, json!({"name": "alice", "x": 1})); + assert_eq!(result, json!({"name": "alice", "nested.x": 1})); + } + + #[test] + fn test_select_fields_no_collision() { + let v = json!({"a": {"id": 1}, "b": {"id": 2}}); + let fields = parse_select("a.id,b.id"); + let result = select_fields(&v, &fields); + assert_eq!(result, json!({"a.id": 1, "b.id": 2})); } } From dee8fdc870584fc03eabf9c9ef583764d2c81e4c Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:44:20 +0000 Subject: [PATCH 18/97] fix: make TUI raw-frame toggle (r) actually render raw frames raw_frames were only collected when show_raw was active, and were never read by any rendering code. Now: - Always collects raw frames (so toggling on shows recent data) - selected_entity_data() checks show_raw first and returns the most recent raw WebSocket frame for the selected entity key --- cli/src/commands/stream/tui/app.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index 7ed2c449..4a0dfeee 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -98,7 +98,8 @@ impl App { recorder.record(&frame); } - let raw_frame = if self.show_raw { Some(frame.clone()) } else { None }; + // Always collect raw frames so toggling on shows recent data + let raw_frame = frame.clone(); let op = frame.operation(); match op { @@ -145,11 +146,9 @@ impl App { } } - if let Some(raw) = raw_frame { - self.raw_frames.push(raw); - if self.raw_frames.len() > 1000 { - self.raw_frames.drain(0..500); - } + self.raw_frames.push(raw_frame); + if self.raw_frames.len() > 1000 { + self.raw_frames.drain(0..500); } } @@ -327,6 +326,12 @@ impl App { pub fn selected_entity_data(&self) -> Option { let key = self.selected_key()?; + // Raw mode: show the most recent raw frame for this entity key + if self.show_raw { + let raw = self.raw_frames.iter().rev().find(|f| f.key == key)?; + return Some(serde_json::to_string_pretty(raw).unwrap_or_default()); + } + if self.show_diff { let diff = self.store.diff_at(&key, self.history_position)?; return Some(serde_json::to_string_pretty(&diff).unwrap_or_default()); From b870c8863a4c985cc8c0f9d95ddc5f57b3dd42e7 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:45:36 +0000 Subject: [PATCH 19/97] fix: add panic hook to restore terminal on TUI crash If the TUI panics, disable_raw_mode and LeaveAlternateScreen never executed, leaving the user's terminal in raw mode (unusable until running 'reset'). Now installs a panic hook before entering raw mode that restores the terminal state before re-invoking the original hook. --- cli/src/commands/stream/tui/mod.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index 4e2abbbf..0762fbe7 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -86,7 +86,14 @@ pub async fn run_tui(url: String, view: &str, args: &StreamArgs) -> Result<()> { } }); - // Setup terminal + // Setup terminal with panic hook to restore on crash + let original_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture); + original_hook(panic_info); + })); + enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; From 77960543709404919a4046b9c90ccf7b2ebed27b Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:47:30 +0000 Subject: [PATCH 20/97] perf: use HashSet for O(1) entity key deduplication in TUI entity_keys.contains() was O(n) per frame, degrading with thousands of entities. Now maintains a parallel HashSet for O(1) membership checks. HashSet::insert returns false if already present, so it doubles as the contains check. Delete also removes from the set. --- cli/src/commands/stream/tui/app.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index 4a0dfeee..fb2bdf03 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -1,6 +1,7 @@ use hyperstack_sdk::{parse_snapshot_entities, Frame, Operation}; use ratatui::widgets::ListState; use serde_json::Value; +use std::collections::HashSet; use crate::commands::stream::snapshot::SnapshotRecorder; use crate::commands::stream::store::EntityStore; @@ -44,6 +45,7 @@ pub struct App { pub url: String, pub view_mode: ViewMode, pub entity_keys: Vec, + entity_key_set: HashSet, pub selected_index: usize, pub history_position: usize, pub show_diff: bool, @@ -71,6 +73,7 @@ impl App { url: url.clone(), view_mode: ViewMode::List, entity_keys: Vec::new(), + entity_key_set: HashSet::new(), selected_index: 0, history_position: 0, show_diff: false, @@ -107,7 +110,7 @@ impl App { let entities = parse_snapshot_entities(&frame.data); for entity in entities { self.store.upsert(&entity.key, entity.data, "snapshot", None); - if !self.entity_keys.contains(&entity.key) { + if self.entity_key_set.insert(entity.key.clone()) { self.entity_keys.push(entity.key); } } @@ -118,7 +121,7 @@ impl App { let seq = frame.seq.clone(); self.store .upsert(&key, frame.data, &frame.op, seq); - if !self.entity_keys.contains(&key) { + if self.entity_key_set.insert(key.clone()) { self.entity_keys.push(key); } self.update_count += 1; @@ -128,13 +131,14 @@ impl App { let seq = frame.seq.clone(); self.store .patch(&key, &frame.data, &frame.append, seq); - if !self.entity_keys.contains(&key) { + if self.entity_key_set.insert(key.clone()) { self.entity_keys.push(key); } self.update_count += 1; } Operation::Delete => { self.store.delete(&frame.key); + self.entity_key_set.remove(&frame.key); self.entity_keys.retain(|k| k != &frame.key); self.update_count += 1; if self.selected_index >= self.entity_keys.len() && self.selected_index > 0 { From ed7a071c4abb82c9b9bf01dc0dd7f701fae79500 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:50:37 +0000 Subject: [PATCH 21/97] fix: resolve clippy warnings in filter module - Use strip_suffix instead of manual string slicing for ? and !? suffix - Use is_some_and/is_none_or instead of map_or for Option comparisons - Use direct == Some() comparison instead of map_or for equality check --- cli/src/commands/stream/filter.rs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/cli/src/commands/stream/filter.rs b/cli/src/commands/stream/filter.rs index cc17f51d..9087f78f 100644 --- a/cli/src/commands/stream/filter.rs +++ b/cli/src/commands/stream/filter.rs @@ -60,16 +60,16 @@ impl Predicate { Some(v) => !value_eq(v, expected), None => true, }, - FilterOp::Gt(n) => resolved.and_then(as_f64).map_or(false, |v| v > *n), - FilterOp::Gte(n) => resolved.and_then(as_f64).map_or(false, |v| v >= *n), - FilterOp::Lt(n) => resolved.and_then(as_f64).map_or(false, |v| v < *n), - FilterOp::Lte(n) => resolved.and_then(as_f64).map_or(false, |v| v <= *n), + FilterOp::Gt(n) => resolved.and_then(as_f64).is_some_and(|v| v > *n), + FilterOp::Gte(n) => resolved.and_then(as_f64).is_some_and(|v| v >= *n), + FilterOp::Lt(n) => resolved.and_then(as_f64).is_some_and(|v| v < *n), + FilterOp::Lte(n) => resolved.and_then(as_f64).is_some_and(|v| v <= *n), FilterOp::Regex(re) => resolved .and_then(|v| v.as_str()) - .map_or(false, |s| re.is_match(s)), + .is_some_and(|s| re.is_match(s)), FilterOp::NotRegex(re) => resolved .and_then(|v| v.as_str()) - .map_or(true, |s| !re.is_match(s)), + .is_none_or(|s| !re.is_match(s)), } } } @@ -87,7 +87,7 @@ fn value_eq(value: &Value, expected: &str) -> bool { Value::String(s) => s == expected, Value::Number(n) => { if let Ok(expected_n) = expected.parse::() { - n.as_f64().map_or(false, |v| v == expected_n) + n.as_f64() == Some(expected_n) } else { n.to_string() == expected } @@ -115,15 +115,13 @@ fn parse_predicate(expr: &str) -> Result { let expr = expr.trim(); // Existence: field? or field!? - if expr.ends_with("!?") { - let field = &expr[..expr.len() - 2]; + if let Some(field) = expr.strip_suffix("!?") { return Ok(Predicate { path: parse_path(field), op: FilterOp::NotExists, }); } - if expr.ends_with('?') { - let field = &expr[..expr.len() - 1]; + if let Some(field) = expr.strip_suffix('?') { return Ok(Predicate { path: parse_path(field), op: FilterOp::Exists, From dbb03db46e32423aea65ba018241c6d9ae144ad6 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:29:49 +0000 Subject: [PATCH 22/97] fix: emit snapshot_complete NO_DNA event for binary WebSocket frames The snapshot_complete detection and NO_DNA event emission only existed in the Message::Text branch. Since the server primarily sends binary frames, consumers relying on the NO_DNA snapshot_complete lifecycle event would never see it. Now mirrors the same tracking logic in the binary frame branch. --- cli/src/commands/stream/client.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 10d6dd4e..9ea2a728 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -136,9 +136,20 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { eprintln!("Subscribed to {}", view); continue; } + let was_snapshot = frame.is_snapshot(); if process_frame(frame, view, &mut state)? { break; } + if !was_snapshot && !snapshot_complete && state.update_count > 0 { + snapshot_complete = true; + if let OutputMode::NoDna = state.output_mode { + output::emit_no_dna_event( + "snapshot_complete", view, + &serde_json::json!({"entity_count": state.entity_count}), + state.update_count, state.entity_count, + )?; + } + } } Err(e) => { // Check if it's a subscribed frame (different shape, no `entity` field) From 7d654c5a83ffefb98d2d782b5e1099b8d5e02a9b Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:33:25 +0000 Subject: [PATCH 23/97] fix: prevent panic on multi-byte UTF-8 entity keys in truncate_key Byte-index slicing panics when the cut point lands in the middle of a multi-byte codepoint (emoji, CJK characters). Now uses char_indices to find safe byte boundaries for truncation. --- cli/src/commands/stream/tui/ui.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/cli/src/commands/stream/tui/ui.rs b/cli/src/commands/stream/tui/ui.rs index bab2eaa8..64460880 100644 --- a/cli/src/commands/stream/tui/ui.rs +++ b/cli/src/commands/stream/tui/ui.rs @@ -210,12 +210,22 @@ fn draw_status_bar(f: &mut Frame, app: &App, area: Rect) { } fn truncate_key(key: &str, max_len: usize) -> String { - if key.len() <= max_len { + if key.chars().count() <= max_len { key.to_string() } else if max_len > 3 { - format!("{}...", &key[..max_len - 3]) + let end = key + .char_indices() + .nth(max_len - 3) + .map(|(i, _)| i) + .unwrap_or(key.len()); + format!("{}...", &key[..end]) } else { - key[..max_len].to_string() + let end = key + .char_indices() + .nth(max_len) + .map(|(i, _)| i) + .unwrap_or(key.len()); + key[..end].to_string() } } From 58f53d0734673b4fb3440843c9bf9b33232c5d70 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:37:51 +0000 Subject: [PATCH 24/97] fix: prevent silent connection to wrong stack when multiple stacks configured Previously fell through to the first stack with any URL, silently connecting to an unrelated stack. Now only auto-selects when there is exactly one stack with a URL (unambiguous), and prints which stack was chosen. With multiple stacks, requires explicit --url or --stack. --- cli/src/commands/stream/mod.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/cli/src/commands/stream/mod.rs b/cli/src/commands/stream/mod.rs index ffb908c3..2aab073c 100644 --- a/cli/src/commands/stream/mod.rs +++ b/cli/src/commands/stream/mod.rs @@ -179,11 +179,13 @@ fn resolve_url(args: &StreamArgs, config_path: &str, view: &str) -> Result = config.stacks.iter().filter(|s| s.url.is_some()).collect(); + if stacks_with_urls.len() == 1 { + let stack = stacks_with_urls[0]; + let name = stack.name.as_deref().unwrap_or(&stack.stack); + eprintln!("Using stack '{}' (only stack with a URL)", name); + return Ok(stack.url.clone().unwrap()); } } From 811cb7aacc66d69dbe50f24067080aca9f9de2bf Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:40:18 +0000 Subject: [PATCH 25/97] fix: --first without --where now exits after first frame/entity Previously --first only triggered when a --where filter was present, silently running forever without one. Now --first always exits after the first output: with --where it exits on first match, without --where it exits after the first frame (raw) or entity (merged). --- cli/src/commands/stream/client.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 9ea2a728..3892045f 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -348,7 +348,7 @@ fn process_frame( } else { output::print_raw_frame(&frame)?; } - return Ok(state.first && !state.filter.is_empty()); + return Ok(state.first); } match op { @@ -447,7 +447,7 @@ fn emit_entity( } } - if state.first && !state.filter.is_empty() { + if state.first { return Ok(true); } From 007635503cfa09b289c3a9301b77aa6bdd99120b Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:41:27 +0000 Subject: [PATCH 26/97] docs: clarify operator parsing order and value splitting in filter DSL Adds comments explaining that two-char operators are checked before single-char to avoid misparsing, and that the split is on the first operator occurrence so values may contain operator characters (e.g. --where "name=a=b" works correctly). --- cli/src/commands/stream/filter.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/stream/filter.rs b/cli/src/commands/stream/filter.rs index 9087f78f..6a011ab7 100644 --- a/cli/src/commands/stream/filter.rs +++ b/cli/src/commands/stream/filter.rs @@ -128,7 +128,10 @@ fn parse_predicate(expr: &str) -> Result { }); } - // Two-char operators: !=, >=, <=, !~ + // Two-char operators checked first so ">=" isn't misread as ">" with value "=...". + // Operator is matched at its first occurrence, so the value portion (after the operator) + // may contain operator characters (e.g. --where "name=a=b" → field="name", value="a=b"). + // This is intentional: the split is on the first operator found, rest is the value. for (op_str, make_op) in &[ ("!=", make_not_eq as fn(&str) -> Result), (">=", make_gte as fn(&str) -> Result), From bf05ff5f1b6c05d2467fe265377c7765be611680 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:49:50 +0000 Subject: [PATCH 27/97] fix: snapshot_complete NO_DNA event tracks received frames, not filtered count Previously used update_count > 0 to detect snapshot completion, which failed when all snapshot entities were filtered out or --no-snapshot was used. Now tracks whether any snapshot frame was received, so the event fires correctly after the last snapshot frame regardless of filter results. --- cli/src/commands/stream/client.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 3892045f..c5112ea6 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -124,6 +124,7 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { tokio::pin!(shutdown); let mut snapshot_complete = false; + let mut received_snapshot = false; loop { tokio::select! { @@ -137,10 +138,11 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { continue; } let was_snapshot = frame.is_snapshot(); + if was_snapshot { received_snapshot = true; } if process_frame(frame, view, &mut state)? { break; } - if !was_snapshot && !snapshot_complete && state.update_count > 0 { + if !was_snapshot && received_snapshot && !snapshot_complete { snapshot_complete = true; if let OutputMode::NoDna = state.output_mode { output::emit_no_dna_event( @@ -152,7 +154,6 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { } } Err(e) => { - // Check if it's a subscribed frame (different shape, no `entity` field) if try_parse_subscribed_frame(&bytes).is_some() { eprintln!("Subscribed to {}", view); } else { @@ -171,10 +172,11 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { match serde_json::from_str::(&text) { Ok(frame) => { let was_snapshot = frame.is_snapshot(); + if was_snapshot { received_snapshot = true; } if process_frame(frame, view, &mut state)? { break; } - if !was_snapshot && !snapshot_complete && state.update_count > 0 { + if !was_snapshot && received_snapshot && !snapshot_complete { snapshot_complete = true; if let OutputMode::NoDna = state.output_mode { output::emit_no_dna_event( From b1d46dacae098b052606a966d3c13eea93dc823b Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:52:35 +0000 Subject: [PATCH 28/97] fix: reject --load --tui with clear error, replace process::exit with bail! --load --tui silently ignored the TUI flag and fell back to non-interactive replay. Now errors with a clear message. Also replaces std::process::exit(1) for missing VIEW with bail!() so errors propagate properly and destructors run. --- cli/src/commands/stream/mod.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/cli/src/commands/stream/mod.rs b/cli/src/commands/stream/mod.rs index 2aab073c..ed383e97 100644 --- a/cli/src/commands/stream/mod.rs +++ b/cli/src/commands/stream/mod.rs @@ -104,6 +104,9 @@ pub struct StreamArgs { pub fn run(args: StreamArgs, config_path: &str) -> Result<()> { // --load mode: replay from file, no WebSocket needed if let Some(load_path) = &args.load { + if args.tui { + bail!("--tui is not yet supported with --load. Use non-interactive replay for now."); + } let player = snapshot::SnapshotPlayer::load(load_path)?; let default_view = player.header.view.clone(); let view = args.view.as_deref().unwrap_or(&default_view); @@ -111,10 +114,10 @@ pub fn run(args: StreamArgs, config_path: &str) -> Result<()> { return rt.block_on(client::replay(player, view, &args)); } - let view = args.view.as_deref().unwrap_or_else(|| { - eprintln!("Error: argument is required (e.g. OreRound/latest)"); - std::process::exit(1); - }); + let view = match args.view.as_deref() { + Some(v) => v, + None => bail!(" argument is required (e.g. OreRound/latest)"), + }; let url = resolve_url(&args, config_path, view)?; From 84ea4fcaa506d7ed8b01a31dd21a9acb94ccb5ba Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:54:06 +0000 Subject: [PATCH 29/97] fix: remove always-on SnapshotRecorder to prevent unbounded memory growth TUI unconditionally allocated a SnapshotRecorder that stored every frame without any cap, causing unbounded memory growth in long-running sessions. Now builds the recorder on-demand from the existing raw_frames ring buffer when the user presses 's' to save. --- cli/src/commands/stream/tui/app.rs | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index fb2bdf03..865808ee 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -63,7 +63,6 @@ pub struct App { pub list_state: ListState, store: EntityStore, raw_frames: Vec, - recorder: Option, } impl App { @@ -91,16 +90,10 @@ impl App { list_state: ListState::default().with_selected(Some(0)), store: EntityStore::new(), raw_frames: Vec::new(), - recorder: Some(SnapshotRecorder::new(&view, &url)), } } pub fn apply_frame(&mut self, frame: Frame) { - // Record for save - if let Some(recorder) = &mut self.recorder { - recorder.record(&frame); - } - // Always collect raw frames so toggling on shows recent data let raw_frame = frame.clone(); let op = frame.operation(); @@ -245,12 +238,14 @@ impl App { self.filter_text.clear(); } TuiAction::SaveSnapshot => { - if let Some(recorder) = &self.recorder { - let filename = format!("hs-stream-{}.json", chrono::Utc::now().format("%Y%m%d-%H%M%S")); - match recorder.save(&filename) { - Ok(_) => self.set_status(&format!("Saved to {}", filename)), - Err(e) => self.set_status(&format!("Save failed: {}", e)), - } + let mut recorder = SnapshotRecorder::new(&self.view, &self.url); + for frame in &self.raw_frames { + recorder.record(frame); + } + let filename = format!("hs-stream-{}.json", chrono::Utc::now().format("%Y%m%d-%H%M%S")); + match recorder.save(&filename) { + Ok(_) => self.set_status(&format!("Saved to {}", filename)), + Err(e) => self.set_status(&format!("Save failed: {}", e)), } } TuiAction::FilterChar(c) => { From ab6bd9b956f9bf41c6e41e66f53a5977d916909d Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:55:22 +0000 Subject: [PATCH 30/97] fix: --duration help text accurately reflects it stops the stream Help said "Auto-stop recording after N seconds (used with --save)" but --duration actually terminates the entire stream, not just recording. Updated to "Auto-stop the stream after N seconds". --- cli/src/commands/stream/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/commands/stream/mod.rs b/cli/src/commands/stream/mod.rs index ed383e97..22c251d6 100644 --- a/cli/src/commands/stream/mod.rs +++ b/cli/src/commands/stream/mod.rs @@ -76,7 +76,7 @@ pub struct StreamArgs { #[arg(long)] pub save: Option, - /// Auto-stop recording after N seconds (used with --save) + /// Auto-stop the stream after N seconds #[arg(long)] pub duration: Option, From 796d5ac2f76921228ca4fe10f136ac616d540798 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:56:53 +0000 Subject: [PATCH 31/97] fix: surface deserialization error when loading corrupt snapshot files Previously swallowed frame deserialization errors with .ok() and returned an empty Vec, showing "0 frames" with no indication of failure. Now propagates the error with context so users see what went wrong (e.g. schema mismatch, partial corruption). --- cli/src/commands/stream/snapshot.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cli/src/commands/stream/snapshot.rs b/cli/src/commands/stream/snapshot.rs index 92924232..383ab3e0 100644 --- a/cli/src/commands/stream/snapshot.rs +++ b/cli/src/commands/stream/snapshot.rs @@ -103,10 +103,11 @@ impl SnapshotPlayer { frame_count: value.get("frame_count").and_then(|v| v.as_u64()).unwrap_or(0), }; - let frames: Vec = value - .get("frames") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); + let frames: Vec = match value.get("frames") { + Some(v) => serde_json::from_value(v.clone()) + .with_context(|| format!("Failed to deserialize frames in {}", path))?, + None => Vec::new(), + }; eprintln!( "Loaded snapshot: {} frames, {:.1}s, view={}, captured={}", From d885322cd8312dcbab2ede2d70721a7d97fe5597 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:15:03 +0000 Subject: [PATCH 32/97] fix: apply --where filter and --first to Delete operations Delete events bypassed the filter predicate and --first check, always emitting regardless of whether the deleted entity matched. Now filters against the entity's last-known state before removal and respects --first. --- cli/src/commands/stream/client.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index c5112ea6..0a2809d2 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -392,11 +392,17 @@ fn process_frame( } } Operation::Delete => { - state.entities.remove(&frame.key); + // Filter against last-known state before removing + let last_state = state.entities.remove(&frame.key).unwrap_or(serde_json::json!(null)); if let Some(store) = &mut state.store { store.delete(&frame.key); } state.entity_count = state.entities.len() as u64; + + if !state.filter.is_empty() && !state.filter.matches(&last_state) { + return Ok(false); + } + state.update_count += 1; if state.count_only { output::print_count(state.update_count)?; @@ -410,6 +416,9 @@ fn process_frame( _ => output::print_delete(view, &frame.key)?, } } + if state.first { + return Ok(true); + } } Operation::Subscribed => {} } From 90e61756e019bae38ded7e6b9be0b4b968ad1d88 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:16:13 +0000 Subject: [PATCH 33/97] fix: emit snapshot_complete NO_DNA event when --no-snapshot is used When --no-snapshot was set, no snapshot frames arrived so received_snapshot stayed false and snapshot_complete was never emitted. Now initializes received_snapshot to true when --no-snapshot is active, so the first live frame triggers the lifecycle event. --- cli/src/commands/stream/client.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 0a2809d2..33147630 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -124,7 +124,9 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { tokio::pin!(shutdown); let mut snapshot_complete = false; - let mut received_snapshot = false; + // When --no-snapshot, treat as if snapshot was already received so + // snapshot_complete fires on the first live frame + let mut received_snapshot = args.no_snapshot; loop { tokio::select! { From 882b21d77018094ea69ee29a01063cc7f4933278 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:19:56 +0000 Subject: [PATCH 34/97] fix: TUI snapshot save preserves frame timing, raw mode handles snapshot entities Previously TUI save created a fresh SnapshotRecorder at save time, so all frames got ts=0. Now stores arrival Instant alongside each frame and uses record_with_ts to preserve actual relative timing. Also fixes raw mode showing nothing for snapshot-ingested entities: falls back to showing the merged state with a note explaining the entity was received via batch snapshot. --- cli/src/commands/stream/snapshot.rs | 7 +++++++ cli/src/commands/stream/tui/app.rs | 28 +++++++++++++++++++++------- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/cli/src/commands/stream/snapshot.rs b/cli/src/commands/stream/snapshot.rs index 383ab3e0..12935b5d 100644 --- a/cli/src/commands/stream/snapshot.rs +++ b/cli/src/commands/stream/snapshot.rs @@ -46,6 +46,13 @@ impl SnapshotRecorder { }); } + pub fn record_with_ts(&mut self, frame: &Frame, ts_ms: u64) { + self.frames.push(SnapshotFrame { + ts: ts_ms, + frame: frame.clone(), + }); + } + pub fn save(&self, path: &str) -> Result<()> { let duration_ms = self.start_time.elapsed().as_millis() as u64; let header = SnapshotHeader { diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index 865808ee..063044a5 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -62,7 +62,8 @@ pub struct App { pub pending_g: bool, pub list_state: ListState, store: EntityStore, - raw_frames: Vec, + raw_frames: Vec<(std::time::Instant, Frame)>, + stream_start: std::time::Instant, } impl App { @@ -90,6 +91,7 @@ impl App { list_state: ListState::default().with_selected(Some(0)), store: EntityStore::new(), raw_frames: Vec::new(), + stream_start: std::time::Instant::now(), } } @@ -143,7 +145,7 @@ impl App { } } - self.raw_frames.push(raw_frame); + self.raw_frames.push((std::time::Instant::now(), raw_frame)); if self.raw_frames.len() > 1000 { self.raw_frames.drain(0..500); } @@ -239,8 +241,9 @@ impl App { } TuiAction::SaveSnapshot => { let mut recorder = SnapshotRecorder::new(&self.view, &self.url); - for frame in &self.raw_frames { - recorder.record(frame); + for (arrival_time, frame) in &self.raw_frames { + let ts_ms = arrival_time.duration_since(self.stream_start).as_millis() as u64; + recorder.record_with_ts(frame, ts_ms); } let filename = format!("hs-stream-{}.json", chrono::Utc::now().format("%Y%m%d-%H%M%S")); match recorder.save(&filename) { @@ -325,10 +328,21 @@ impl App { pub fn selected_entity_data(&self) -> Option { let key = self.selected_key()?; - // Raw mode: show the most recent raw frame for this entity key + // Raw mode: show the most recent raw frame for this entity key. + // Snapshot frames have key="" (entities are in data array), so fall back + // to showing the merged state with a note for snapshot-only entities. if self.show_raw { - let raw = self.raw_frames.iter().rev().find(|f| f.key == key)?; - return Some(serde_json::to_string_pretty(raw).unwrap_or_default()); + if let Some((_, raw)) = self.raw_frames.iter().rev().find(|(_, f)| f.key == key) { + return Some(serde_json::to_string_pretty(raw).unwrap_or_default()); + } + // Entity was ingested via snapshot batch — no individual raw frame exists + let record = self.store.get(&key)?; + let fallback = serde_json::json!({ + "_note": "Received via snapshot batch (no individual raw frame)", + "key": key, + "data": record.current, + }); + return Some(serde_json::to_string_pretty(&fallback).unwrap_or_default()); } if self.show_diff { From c0d707c2cfa0bccf6c56de366aaada8a82f994c4 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:22:31 +0000 Subject: [PATCH 35/97] fix: delay first ping tick by 30s instead of firing immediately tokio::time::interval fires its first tick at t=0, sending a Ping immediately after subscribing. Now uses interval_at to delay the first tick by the full 30s period. Fixed in both client.rs and tui/mod.rs. --- cli/src/commands/stream/client.rs | 3 ++- cli/src/commands/stream/tui/mod.rs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 33147630..5f363372 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -107,7 +107,8 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { let mut state = build_state(args, view, &url)?; // Ping interval - let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(30)); + let ping_period = std::time::Duration::from_secs(30); + let mut ping_interval = tokio::time::interval_at(tokio::time::Instant::now() + ping_period, ping_period); // Duration timer for --save --duration (as a select! arm for precise timing) let duration_future = async { diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index 0762fbe7..3dcdcb41 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -51,7 +51,8 @@ pub async fn run_tui(url: String, view: &str, args: &StreamArgs) -> Result<()> { // Spawn WS reader task let ws_handle = tokio::spawn(async move { - let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(30)); + let ping_period = std::time::Duration::from_secs(30); + let mut ping_interval = tokio::time::interval_at(tokio::time::Instant::now() + ping_period, ping_period); loop { tokio::select! { msg = ws_rx.next() => { From 9af158ad3186ca382fb920bafeb29a700a01e280 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:32:54 +0000 Subject: [PATCH 36/97] fix: gate record_with_ts behind tui feature to avoid dead_code warning Only used from the TUI module, so compiling without --features tui flagged it as dead code. --- cli/src/commands/stream/snapshot.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/src/commands/stream/snapshot.rs b/cli/src/commands/stream/snapshot.rs index 12935b5d..eae00614 100644 --- a/cli/src/commands/stream/snapshot.rs +++ b/cli/src/commands/stream/snapshot.rs @@ -46,6 +46,7 @@ impl SnapshotRecorder { }); } + #[cfg(feature = "tui")] pub fn record_with_ts(&mut self, frame: &Frame, ts_ms: u64) { self.frames.push(SnapshotFrame { ts: ts_ms, From fe47712f49d0dc1132a6715c798f21a22ca3f522 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:34:14 +0000 Subject: [PATCH 37/97] fix: normalize op_str to lowercase in --ops filter comparison The allowed_ops set is lowercase but the frame op string was compared as-is, so mixed-case ops from the server would be silently dropped. --- cli/src/commands/stream/client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 5f363372..9a557ca5 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -338,7 +338,7 @@ fn process_frame( // Filter by operation type if let Some(allowed) = &state.allowed_ops { - if op != Operation::Snapshot && !allowed.contains(op_str.as_str()) { + if op != Operation::Snapshot && !allowed.contains(op_str.to_lowercase().as_str()) { return Ok(false); } } From 81f410e69dec1ef5101fb1e6e08dfa795b11714a Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:52:49 +0000 Subject: [PATCH 38/97] fix: address final review feedback (5 items) - !~ (NotRegex) filter now returns false for missing fields, consistent with all other negative operators - snapshot_complete NO_DNA event emitted before disconnected when --first exits during snapshot phase - Extract shared build_subscription() helper, removing duplicated subscription-building logic between client.rs and tui/mod.rs - TUI sends graceful WebSocket Close frame via oneshot shutdown signal instead of aborting the task - print_count flushes stderr so \r overwrite works in piped contexts --- cli/src/commands/stream/client.rs | 31 +++++++++++------------------- cli/src/commands/stream/filter.rs | 7 ++++--- cli/src/commands/stream/mod.rs | 21 ++++++++++++++++++++ cli/src/commands/stream/output.rs | 2 ++ cli/src/commands/stream/tui/mod.rs | 31 ++++++++++++------------------ 5 files changed, 50 insertions(+), 42 deletions(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 9a557ca5..77f08557 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use futures_util::{SinkExt, StreamExt}; use hyperstack_sdk::{ deep_merge_with_append, parse_frame, parse_snapshot_entities, try_parse_subscribed_frame, - ClientMessage, Frame, Operation, Subscription, + ClientMessage, Frame, Operation, }; use std::collections::{HashMap, HashSet}; use tokio_tungstenite::{connect_async, tungstenite::Message}; @@ -78,25 +78,8 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { let (mut ws_tx, mut ws_rx) = ws.split(); - // Build subscription - let mut sub = Subscription::new(view); - if let Some(key) = &args.key { - sub = sub.with_key(key.clone()); - } - if let Some(take) = args.take { - sub = sub.with_take(take); - } - if let Some(skip) = args.skip { - sub = sub.with_skip(skip); - } - if args.no_snapshot { - sub = sub.with_snapshot(false); - } - if let Some(after) = &args.after { - sub = sub.after(after.clone()); - } - - // Send subscribe message + // Build and send subscription + let sub = super::build_subscription(view, args); let msg = serde_json::to_string(&ClientMessage::Subscribe(sub)) .context("Failed to serialize subscribe message")?; ws_tx @@ -234,6 +217,14 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { } if let OutputMode::NoDna = state.output_mode { + // Ensure snapshot_complete is emitted before disconnected if it wasn't already + if !snapshot_complete && state.update_count > 0 { + output::emit_no_dna_event( + "snapshot_complete", view, + &serde_json::json!({"entity_count": state.entity_count}), + state.update_count, state.entity_count, + )?; + } output::emit_no_dna_event( "disconnected", view, &serde_json::json!(null), diff --git a/cli/src/commands/stream/filter.rs b/cli/src/commands/stream/filter.rs index 6a011ab7..39dba0c1 100644 --- a/cli/src/commands/stream/filter.rs +++ b/cli/src/commands/stream/filter.rs @@ -67,9 +67,10 @@ impl Predicate { FilterOp::Regex(re) => resolved .and_then(|v| v.as_str()) .is_some_and(|s| re.is_match(s)), - FilterOp::NotRegex(re) => resolved - .and_then(|v| v.as_str()) - .is_none_or(|s| !re.is_match(s)), + FilterOp::NotRegex(re) => match resolved.and_then(|v| v.as_str()) { + Some(s) => !re.is_match(s), + None => false, // field absent or non-string → does not match + }, } } } diff --git a/cli/src/commands/stream/mod.rs b/cli/src/commands/stream/mod.rs index 22c251d6..20e68645 100644 --- a/cli/src/commands/stream/mod.rs +++ b/cli/src/commands/stream/mod.rs @@ -8,6 +8,7 @@ mod tui; use anyhow::{bail, Context, Result}; use clap::Args; +use hyperstack_sdk::Subscription; use crate::config::HyperstackConfig; @@ -143,6 +144,26 @@ pub fn run(args: StreamArgs, config_path: &str) -> Result<()> { rt.block_on(client::stream(url, view, &args)) } +pub fn build_subscription(view: &str, args: &StreamArgs) -> Subscription { + let mut sub = Subscription::new(view); + if let Some(key) = &args.key { + sub = sub.with_key(key.clone()); + } + if let Some(take) = args.take { + sub = sub.with_take(take); + } + if let Some(skip) = args.skip { + sub = sub.with_skip(skip); + } + if args.no_snapshot { + sub = sub.with_snapshot(false); + } + if let Some(after) = &args.after { + sub = sub.after(after.clone()); + } + sub +} + fn resolve_url(args: &StreamArgs, config_path: &str, view: &str) -> Result { // 1. Explicit --url if let Some(url) = &args.url { diff --git a/cli/src/commands/stream/output.rs b/cli/src/commands/stream/output.rs index 0d51fb95..531a5dd5 100644 --- a/cli/src/commands/stream/output.rs +++ b/cli/src/commands/stream/output.rs @@ -55,6 +55,8 @@ pub fn print_delete(view: &str, key: &str) -> Result<()> { /// Print a running update count to stderr (overwrites line). pub fn print_count(count: u64) -> Result<()> { eprint!("\rUpdates: {}", count); + use std::io::Write; + std::io::stderr().flush()?; Ok(()) } diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index 3dcdcb41..5e5c407c 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -8,7 +8,7 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use futures_util::{SinkExt, StreamExt}; -use hyperstack_sdk::{parse_frame, ClientMessage, Frame, Subscription}; +use hyperstack_sdk::{parse_frame, ClientMessage, Frame}; use ratatui::{backend::CrosstermBackend, Terminal}; use std::io; use tokio::sync::mpsc; @@ -26,35 +26,26 @@ pub async fn run_tui(url: String, view: &str, args: &StreamArgs) -> Result<()> { let (mut ws_tx, mut ws_rx) = ws.split(); // Subscribe - let mut sub = Subscription::new(view); - if let Some(key) = &args.key { - sub = sub.with_key(key.clone()); - } - if let Some(take) = args.take { - sub = sub.with_take(take); - } - if let Some(skip) = args.skip { - sub = sub.with_skip(skip); - } - if args.no_snapshot { - sub = sub.with_snapshot(false); - } - if let Some(after) = &args.after { - sub = sub.after(after.clone()); - } - + let sub = crate::commands::stream::build_subscription(view, args); let msg = serde_json::to_string(&ClientMessage::Subscribe(sub))?; ws_tx.send(Message::Text(msg)).await?; // Channel for frames from WS task let (frame_tx, mut frame_rx) = mpsc::channel::(1000); + // Shutdown signal for graceful WebSocket close + let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + // Spawn WS reader task let ws_handle = tokio::spawn(async move { let ping_period = std::time::Duration::from_secs(30); let mut ping_interval = tokio::time::interval_at(tokio::time::Instant::now() + ping_period, ping_period); loop { tokio::select! { + _ = &mut shutdown_rx => { + let _ = ws_tx.close().await; + break; + } msg = ws_rx.next() => { match msg { Some(Ok(Message::Binary(bytes))) => { @@ -116,7 +107,9 @@ pub async fn run_tui(url: String, view: &str, args: &StreamArgs) -> Result<()> { )?; terminal.show_cursor()?; - ws_handle.abort(); + // Signal graceful shutdown, then wait briefly for the task to close + let _ = shutdown_tx.send(()); + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ws_handle).await; result } From e4cfbe3c06d07635bb69ff0414ea321cd8bd4e54 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:03:43 +0000 Subject: [PATCH 39/97] fix: emit snapshot_complete in replay NoDna mode replay() emitted connected and disconnected but never snapshot_complete, breaking the NO_DNA v1 lifecycle contract for agents consuming --load --no-dna output. --- cli/src/commands/stream/client.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 77f08557..882342d2 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -249,6 +249,11 @@ pub async fn replay(player: SnapshotPlayer, view: &str, args: &StreamArgs) -> Re } if let OutputMode::NoDna = state.output_mode { + output::emit_no_dna_event( + "snapshot_complete", view, + &serde_json::json!({"entity_count": state.entity_count}), + state.update_count, state.entity_count, + )?; output::emit_no_dna_event( "disconnected", view, &serde_json::json!(null), From 2f3fd5b724844095f1cf74d24f8c92fe1eb62c13 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:04:44 +0000 Subject: [PATCH 40/97] fix: disable raw mode on terminal setup failure If execute!(EnterAlternateScreen) or Terminal::new failed after enable_raw_mode(), the function returned early via ? without restoring the terminal. Now catches setup errors and calls disable_raw_mode() before propagating. --- cli/src/commands/stream/tui/mod.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index 5e5c407c..a710de91 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -87,10 +87,19 @@ pub async fn run_tui(url: String, view: &str, args: &StreamArgs) -> Result<()> { })); enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; + let terminal_setup = || -> Result>> { + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + Ok(Terminal::new(backend)?) + }; + let mut terminal = match terminal_setup() { + Ok(t) => t, + Err(e) => { + let _ = disable_raw_mode(); + return Err(e); + } + }; let mut app = App::new(view.to_string(), url.clone()); From 703f4b44b59aac8e602890c41d7e6ef4ef8e4a74 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:08:10 +0000 Subject: [PATCH 41/97] fix: stop dropping frames while TUI is paused Previously frames were drained from the channel and discarded while paused, permanently losing updates. Now leaves frames in the channel when paused so they're applied on resume, preserving entity state continuity. --- cli/src/commands/stream/tui/mod.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index a710de91..61065c54 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -136,9 +136,10 @@ async fn run_loop( terminal.draw(|f| ui::draw(f, app))?; - // Drain available frames (non-blocking) - while let Ok(frame) = frame_rx.try_recv() { - if !app.paused { + // Drain available frames (non-blocking). When paused, leave + // frames in the channel so they're applied on resume. + if !app.paused { + while let Ok(frame) = frame_rx.try_recv() { app.apply_frame(frame); } } From ff68c573f45671ba7739380ea8142cc88cc3623b Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:18:00 +0000 Subject: [PATCH 42/97] fix: include snapshot operations in --ops filter Snapshot entities previously bypassed the --ops filter entirely, so --ops patch would still emit all snapshot entities. Now treats snapshot frames as op type "snapshot" in the filter check, so --ops patch only shows patches and --ops snapshot,upsert shows both. --- cli/src/commands/stream/client.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 882342d2..fe907a27 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -332,9 +332,10 @@ fn process_frame( let op = frame.operation(); let op_str = &frame.op; - // Filter by operation type + // Filter by operation type (snapshot ops are checked as "snapshot") if let Some(allowed) = &state.allowed_ops { - if op != Operation::Snapshot && !allowed.contains(op_str.to_lowercase().as_str()) { + let effective_op = if op == Operation::Snapshot { "snapshot" } else { &op_str.to_lowercase() }; + if !allowed.contains(effective_op) { return Ok(false); } } From 5161913431b70322ec5199fd2d56f86e01153f0e Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:23:12 +0000 Subject: [PATCH 43/97] fix: use derived Deserialize for SnapshotHeader instead of manual parsing Manual field-by-field extraction silently defaulted to empty/zero on missing fields and would miss new fields without compile-time warning. Now uses serde_json::from_value with the derived Deserialize impl, which propagates errors on invalid/missing fields. --- cli/src/commands/stream/snapshot.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/cli/src/commands/stream/snapshot.rs b/cli/src/commands/stream/snapshot.rs index eae00614..5c9e44b9 100644 --- a/cli/src/commands/stream/snapshot.rs +++ b/cli/src/commands/stream/snapshot.rs @@ -102,14 +102,8 @@ impl SnapshotPlayer { let value: serde_json::Value = serde_json::from_str(&contents) .with_context(|| format!("Failed to parse snapshot JSON: {}", path))?; - let header = SnapshotHeader { - version: value.get("version").and_then(|v| v.as_u64()).unwrap_or(1) as u32, - view: value.get("view").and_then(|v| v.as_str()).unwrap_or("").to_string(), - url: value.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string(), - captured_at: value.get("captured_at").and_then(|v| v.as_str()).unwrap_or("").to_string(), - duration_ms: value.get("duration_ms").and_then(|v| v.as_u64()).unwrap_or(0), - frame_count: value.get("frame_count").and_then(|v| v.as_u64()).unwrap_or(0), - }; + let header: SnapshotHeader = serde_json::from_value(value.clone()) + .with_context(|| format!("Failed to deserialize snapshot header: {}", path))?; let frames: Vec = match value.get("frames") { Some(v) => serde_json::from_value(v.clone()) From e892f465eca56a2f1347653b21c9fd628bb6d6ee Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:24:36 +0000 Subject: [PATCH 44/97] fix: TUI update_count increments per entity, not per snapshot frame A snapshot frame containing 500 entities incremented update_count by 1 instead of 500, making the TUI header inconsistent with the CLI output. Now counts each entity individually. --- cli/src/commands/stream/tui/app.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index 063044a5..a849cfbb 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -103,13 +103,14 @@ impl App { match op { Operation::Snapshot => { let entities = parse_snapshot_entities(&frame.data); + let count = entities.len() as u64; for entity in entities { self.store.upsert(&entity.key, entity.data, "snapshot", None); if self.entity_key_set.insert(entity.key.clone()) { self.entity_keys.push(entity.key); } } - self.update_count += 1; + self.update_count += count; } Operation::Upsert | Operation::Create => { let key = frame.key.clone(); From 29a08b77acedb0eb7564bc2e5c673f224f913af4 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:25:36 +0000 Subject: [PATCH 45/97] docs: note that field? treats null as absent in --where help text The Exists operator returns false for JSON null values, which may surprise users who distinguish between missing keys and explicit nulls. Added a note to the help text. --- cli/src/commands/stream/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/stream/mod.rs b/cli/src/commands/stream/mod.rs index 20e68645..64c86fe4 100644 --- a/cli/src/commands/stream/mod.rs +++ b/cli/src/commands/stream/mod.rs @@ -37,7 +37,8 @@ pub struct StreamArgs { #[arg(long)] pub no_dna: bool, - /// Filter expression: field=value, field>N, field~regex (repeatable, ANDed) + /// Filter expression: field=value, field>N, field~regex (repeatable, ANDed). + /// Note: field? treats null as absent (returns false for null values) #[arg(long = "where", value_name = "EXPR")] pub filters: Vec, From 8832ff389b35ce64a709ecf7a50b1ed8aa4dfa0f Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:38:11 +0000 Subject: [PATCH 46/97] feat: add detail pane scrolling with Ctrl+e/Ctrl+y and PageDown/PageUp scroll_offset was never incremented, making the detail pane stuck at the top. Large entity JSON was truncated with no way to see the rest. Now Ctrl+e/Ctrl+y scroll line-by-line (with count prefix support) and PageDown/PageUp also work. --- cli/src/commands/stream/tui/app.rs | 11 +++++++++++ cli/src/commands/stream/tui/mod.rs | 8 ++++++++ 2 files changed, 19 insertions(+) diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index a849cfbb..3f8e610d 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -25,6 +25,9 @@ pub enum TuiAction { SaveSnapshot, FilterChar(char), FilterBackspace, + // Detail pane scroll + ScrollDetailDown, + ScrollDetailUp, // Vim motions GotoTop, GotoBottom, @@ -169,6 +172,14 @@ impl App { match action { TuiAction::Quit => {} + TuiAction::ScrollDetailDown => { + let n = self.take_count(); + self.scroll_offset = self.scroll_offset.saturating_add(n as u16); + } + TuiAction::ScrollDetailUp => { + let n = self.take_count(); + self.scroll_offset = self.scroll_offset.saturating_sub(n as u16); + } TuiAction::NextEntity => { let n = self.take_count(); let count = self.filtered_keys().len(); diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index 61065c54..7908e36f 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -195,6 +195,14 @@ async fn run_loop( KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { TuiAction::HalfPageUp } + KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { + TuiAction::ScrollDetailDown + } + KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => { + TuiAction::ScrollDetailUp + } + KeyCode::PageDown => TuiAction::ScrollDetailDown, + KeyCode::PageUp => TuiAction::ScrollDetailUp, KeyCode::Char('n') => TuiAction::NextMatch, KeyCode::Enter => TuiAction::FocusDetail, KeyCode::Esc => { From e968560f0d6c3b164353f1213b77e3a1253c87eb Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:40:13 +0000 Subject: [PATCH 47/97] fix: --ops filter suppresses output but always populates entity state Previously --ops without "snapshot" dropped snapshot frames entirely, so later patch merges ran against empty {} state producing incorrect output. Now always processes frames for state tracking but only emits output when the op type passes the filter. This ensures patches merge correctly against snapshot-populated entity state. --- cli/src/commands/stream/client.rs | 40 +++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index fe907a27..a754000a 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -332,15 +332,20 @@ fn process_frame( let op = frame.operation(); let op_str = &frame.op; - // Filter by operation type (snapshot ops are checked as "snapshot") - if let Some(allowed) = &state.allowed_ops { - let effective_op = if op == Operation::Snapshot { "snapshot" } else { &op_str.to_lowercase() }; - if !allowed.contains(effective_op) { - return Ok(false); + // Check if this op type is allowed by --ops (but always process snapshots + // for entity state — just suppress their output) + let ops_allowed = match &state.allowed_ops { + Some(allowed) => { + let effective_op = if op == Operation::Snapshot { "snapshot" } else { &op_str.to_lowercase() }; + allowed.contains(effective_op) } - } + None => true, + }; if let OutputMode::Raw = state.output_mode { + if !ops_allowed { + return Ok(false); + } if !state.filter.is_empty() && !state.filter.matches(&frame.data) { return Ok(false); } @@ -357,13 +362,16 @@ fn process_frame( Operation::Snapshot => { let snapshot_entities = parse_snapshot_entities(&frame.data); for entity in snapshot_entities { + // Always populate entity state (needed for correct patch merging) state.entities.insert(entity.key.clone(), entity.data.clone()); if let Some(store) = &mut state.store { store.upsert(&entity.key, entity.data.clone(), "snapshot", None); } state.entity_count = state.entities.len() as u64; - if emit_entity(state, view, &entity.key, "snapshot", &entity.data)? { - return Ok(true); + if ops_allowed { + if emit_entity(state, view, &entity.key, "snapshot", &entity.data)? { + return Ok(true); + } } } } @@ -373,8 +381,10 @@ fn process_frame( store.upsert(&frame.key, frame.data.clone(), op_str, frame.seq.clone()); } state.entity_count = state.entities.len() as u64; - if emit_entity(state, view, &frame.key, op_str, &frame.data)? { - return Ok(true); + if ops_allowed { + if emit_entity(state, view, &frame.key, op_str, &frame.data)? { + return Ok(true); + } } } Operation::Patch => { @@ -387,18 +397,22 @@ fn process_frame( deep_merge_with_append(entry, &frame.data, &frame.append, ""); let merged = entry.clone(); state.entity_count = state.entities.len() as u64; - if emit_entity(state, view, &frame.key, "patch", &merged)? { - return Ok(true); + if ops_allowed { + if emit_entity(state, view, &frame.key, "patch", &merged)? { + return Ok(true); + } } } Operation::Delete => { - // Filter against last-known state before removing let last_state = state.entities.remove(&frame.key).unwrap_or(serde_json::json!(null)); if let Some(store) = &mut state.store { store.delete(&frame.key); } state.entity_count = state.entities.len() as u64; + if !ops_allowed { + return Ok(false); + } if !state.filter.is_empty() && !state.filter.matches(&last_state) { return Ok(false); } From 77ed9e7460f5aec49368cddd5cb26d282c898b86 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:42:26 +0000 Subject: [PATCH 48/97] docs: document --raw --where limitation for snapshot batch frames In raw mode, --where filters against the raw frame.data which is a JSON array for snapshot frames. Field-level predicates won't match snapshot batches. Added a comment explaining the trade-off. --- cli/src/commands/stream/client.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index a754000a..e230493e 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -346,6 +346,9 @@ fn process_frame( if !ops_allowed { return Ok(false); } + // Note: in raw mode, --where filters against the raw frame.data which is + // an array for snapshot frames. Field-level filters (e.g. --where "info.name=X") + // will not match snapshot batch arrays — use merged mode for field filtering. if !state.filter.is_empty() && !state.filter.matches(&frame.data) { return Ok(false); } From 1094aec16d63fc6ddbe4e41ebea0a2fc6f205579 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:43:46 +0000 Subject: [PATCH 49/97] fix: validate snapshot version on load Previously ignored the version field, so future format changes would cause silent or confusing deserialization failures. Now rejects unsupported versions with a clear error message. --- cli/src/commands/stream/snapshot.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cli/src/commands/stream/snapshot.rs b/cli/src/commands/stream/snapshot.rs index 5c9e44b9..07cfe77a 100644 --- a/cli/src/commands/stream/snapshot.rs +++ b/cli/src/commands/stream/snapshot.rs @@ -105,6 +105,14 @@ impl SnapshotPlayer { let header: SnapshotHeader = serde_json::from_value(value.clone()) .with_context(|| format!("Failed to deserialize snapshot header: {}", path))?; + if header.version != 1 { + anyhow::bail!( + "Unsupported snapshot version {} in {}. This CLI supports version 1.", + header.version, + path + ); + } + let frames: Vec = match value.get("frames") { Some(v) => serde_json::from_value(v.clone()) .with_context(|| format!("Failed to deserialize frames in {}", path))?, From e7c247edc8411ec46f6d030ac347cb3fa7993fc5 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:50:47 +0000 Subject: [PATCH 50/97] fix: clear pending_count on g prefix, add resize handling comment - Clears pending_count when g sets pending_g, preventing a spurious count from being consumed if the user presses a non-g key between the two g presses - Adds comment documenting that resize events are handled implicitly via terminal.size() recalculation at the top of each loop iteration - Collapses nested if statements per clippy --- cli/src/commands/stream/client.rs | 18 ++++++------------ cli/src/commands/stream/tui/mod.rs | 3 +++ 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index e230493e..878f979f 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -371,10 +371,8 @@ fn process_frame( store.upsert(&entity.key, entity.data.clone(), "snapshot", None); } state.entity_count = state.entities.len() as u64; - if ops_allowed { - if emit_entity(state, view, &entity.key, "snapshot", &entity.data)? { - return Ok(true); - } + if ops_allowed && emit_entity(state, view, &entity.key, "snapshot", &entity.data)? { + return Ok(true); } } } @@ -384,10 +382,8 @@ fn process_frame( store.upsert(&frame.key, frame.data.clone(), op_str, frame.seq.clone()); } state.entity_count = state.entities.len() as u64; - if ops_allowed { - if emit_entity(state, view, &frame.key, op_str, &frame.data)? { - return Ok(true); - } + if ops_allowed && emit_entity(state, view, &frame.key, op_str, &frame.data)? { + return Ok(true); } } Operation::Patch => { @@ -400,10 +396,8 @@ fn process_frame( deep_merge_with_append(entry, &frame.data, &frame.append, ""); let merged = entry.clone(); state.entity_count = state.entities.len() as u64; - if ops_allowed { - if emit_entity(state, view, &frame.key, "patch", &merged)? { - return Ok(true); - } + if ops_allowed && emit_entity(state, view, &frame.key, "patch", &merged)? { + return Ok(true); } } Operation::Delete => { diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index 7908e36f..89b25b1e 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -186,6 +186,7 @@ async fn run_loop( TuiAction::GotoTop } else { app.pending_g = true; + app.pending_count = None; continue; } } @@ -238,6 +239,8 @@ async fn run_loop( } app.handle_action(action); } + // Resize and other events are handled implicitly: + // layout is recalculated from terminal.size() at the top of each loop iteration } } From e36d1eab4d3802d5591ee3af49757782c14c7149 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 22:07:29 +0000 Subject: [PATCH 51/97] fix: always attempt all terminal cleanup steps on TUI exit Used ? which would short-circuit if disable_raw_mode failed, skipping LeaveAlternateScreen and show_cursor. Now uses let _ = to ignore errors and attempt every cleanup step regardless. --- cli/src/commands/stream/tui/mod.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index 89b25b1e..df7d97a0 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -107,14 +107,14 @@ pub async fn run_tui(url: String, view: &str, args: &StreamArgs) -> Result<()> { let tick_rate = std::time::Duration::from_millis(50); let result = run_loop(&mut terminal, &mut app, &mut frame_rx, tick_rate).await; - // Restore terminal - disable_raw_mode()?; - execute!( + // Restore terminal (always attempt all steps) + let _ = disable_raw_mode(); + let _ = execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture, - )?; - terminal.show_cursor()?; + ); + let _ = terminal.show_cursor(); // Signal graceful shutdown, then wait briefly for the task to close let _ = shutdown_tx.send(()); From 56bf945b73e5ec6479540b189932c43426550c8d Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 22:12:07 +0000 Subject: [PATCH 52/97] fix: snapshot_complete fallback guard uses received_snapshot not update_count When --ops excludes snapshots, update_count stays 0 despite snapshots being received (for state tracking). The fallback guard now checks received_snapshot so the NO_DNA lifecycle event fires correctly. --- cli/src/commands/stream/client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 878f979f..6bf70bf5 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -218,7 +218,7 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { if let OutputMode::NoDna = state.output_mode { // Ensure snapshot_complete is emitted before disconnected if it wasn't already - if !snapshot_complete && state.update_count > 0 { + if !snapshot_complete && received_snapshot { output::emit_no_dna_event( "snapshot_complete", view, &serde_json::json!({"entity_count": state.entity_count}), From ab2b650afd51e7c06c7d185f63822c2a3263a29b Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 22:13:03 +0000 Subject: [PATCH 53/97] fix: warn when snapshot file has no 'frames' key Previously returned an empty player silently, showing "0 frames" with no indication the file was malformed. Now prints a warning. --- cli/src/commands/stream/snapshot.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/stream/snapshot.rs b/cli/src/commands/stream/snapshot.rs index 07cfe77a..86dd4e92 100644 --- a/cli/src/commands/stream/snapshot.rs +++ b/cli/src/commands/stream/snapshot.rs @@ -116,7 +116,10 @@ impl SnapshotPlayer { let frames: Vec = match value.get("frames") { Some(v) => serde_json::from_value(v.clone()) .with_context(|| format!("Failed to deserialize frames in {}", path))?, - None => Vec::new(), + None => { + eprintln!("Warning: snapshot file {} has no 'frames' key — replaying 0 frames.", path); + Vec::new() + } }; eprintln!( From 57b328e1ac03cbad2e86768f297f91c2d0352b3d Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 22:14:25 +0000 Subject: [PATCH 54/97] fix: validate WebSocket URL scheme before connecting Passing http:// or https:// instead of ws://wss:// produced a cryptic error from tokio-tungstenite. Now validates the scheme upfront with a clear error message. --- cli/src/commands/stream/mod.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cli/src/commands/stream/mod.rs b/cli/src/commands/stream/mod.rs index 64c86fe4..eb96a7bb 100644 --- a/cli/src/commands/stream/mod.rs +++ b/cli/src/commands/stream/mod.rs @@ -168,6 +168,12 @@ pub fn build_subscription(view: &str, args: &StreamArgs) -> Subscription { fn resolve_url(args: &StreamArgs, config_path: &str, view: &str) -> Result { // 1. Explicit --url if let Some(url) = &args.url { + if !url.starts_with("ws://") && !url.starts_with("wss://") { + bail!( + "Invalid URL scheme. Expected ws:// or wss://, got: {}", + url + ); + } return Ok(url.clone()); } From 3f7d37a9b1ee5904e27a9059aa8f14369af6446c Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Mon, 23 Mar 2026 22:15:28 +0000 Subject: [PATCH 55/97] fix: warn when --diff and --history are combined --diff takes precedence and silently ignores --history. Now prints a warning so the user knows to remove --diff to see full history. --- cli/src/commands/stream/client.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 6bf70bf5..5840de4d 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -284,6 +284,10 @@ fn output_history_if_requested(state: &StreamState, args: &StreamArgs) -> Result } }; + if args.diff && args.history { + eprintln!("Warning: --history is ignored when --diff is specified. Remove --diff to see full history."); + } + if args.diff { let index = args.at.unwrap_or(0); if let Some(diff) = store.diff_at(key, index) { From 3f5c40dbc5de6c75d2b3368c710d5fb6df53ea98 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:16:45 +0000 Subject: [PATCH 56/97] fix: emit snapshot_complete before first live frame in NO_DNA mode Previously snapshot_complete was emitted after process_frame, so the first live entity_update appeared before the snapshot_complete event, violating NO_DNA lifecycle ordering. Now emits the transition event before processing the first live frame. Also fixes --no-snapshot emitting entity_count:1 instead of 0. --- cli/src/commands/stream/client.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 5840de4d..f0803dba 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -125,9 +125,7 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { } let was_snapshot = frame.is_snapshot(); if was_snapshot { received_snapshot = true; } - if process_frame(frame, view, &mut state)? { - break; - } + // Emit snapshot_complete BEFORE the first live frame if !was_snapshot && received_snapshot && !snapshot_complete { snapshot_complete = true; if let OutputMode::NoDna = state.output_mode { @@ -138,6 +136,9 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { )?; } } + if process_frame(frame, view, &mut state)? { + break; + } } Err(e) => { if try_parse_subscribed_frame(&bytes).is_some() { @@ -159,9 +160,7 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { Ok(frame) => { let was_snapshot = frame.is_snapshot(); if was_snapshot { received_snapshot = true; } - if process_frame(frame, view, &mut state)? { - break; - } + // Emit snapshot_complete BEFORE the first live frame if !was_snapshot && received_snapshot && !snapshot_complete { snapshot_complete = true; if let OutputMode::NoDna = state.output_mode { @@ -172,6 +171,9 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { )?; } } + if process_frame(frame, view, &mut state)? { + break; + } } Err(e) => eprintln!("Warning: failed to parse text frame: {}", e), } From f8bd75eb020adc9968478b8e3e82fbb5c58efbc8 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:17:44 +0000 Subject: [PATCH 57/97] fix: reject TUI-incompatible flags with clear error messages --duration, --count, --save, --history, --at, and --diff were silently ignored in --tui mode. Now bail with messages pointing to TUI equivalents. --- cli/src/commands/stream/mod.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cli/src/commands/stream/mod.rs b/cli/src/commands/stream/mod.rs index eb96a7bb..d4c794f9 100644 --- a/cli/src/commands/stream/mod.rs +++ b/cli/src/commands/stream/mod.rs @@ -126,6 +126,18 @@ pub fn run(args: StreamArgs, config_path: &str) -> Result<()> { let rt = tokio::runtime::Runtime::new().context("Failed to create async runtime")?; if args.tui { + if args.duration.is_some() { + bail!("--duration has no effect in TUI mode; stop with 'q' or Ctrl+C."); + } + if args.count { + bail!("--count is incompatible with TUI mode."); + } + if args.save.is_some() { + bail!("--save is not yet supported in TUI mode; use 's' inside the TUI to save."); + } + if args.history || args.at.is_some() || args.diff { + bail!("--history/--at/--diff are not supported in TUI mode; use h/l keys to browse history."); + } #[cfg(feature = "tui")] { return rt.block_on(tui::run_tui(url, view, &args)); From 7af7ede97a4b0a3bea26372038558656c5afc28f Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:19:12 +0000 Subject: [PATCH 58/97] fix: deserialize snapshot in single pass to avoid cloning entire JSON Previously cloned the full serde_json::Value (including all frames) just to parse the small header. Now uses a combined SnapshotFile struct with #[serde(flatten)] for one-pass deserialization. --- cli/src/commands/stream/snapshot.rs | 40 ++++++++++++++++------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/cli/src/commands/stream/snapshot.rs b/cli/src/commands/stream/snapshot.rs index 86dd4e92..eb6b7d34 100644 --- a/cli/src/commands/stream/snapshot.rs +++ b/cli/src/commands/stream/snapshot.rs @@ -94,42 +94,46 @@ pub struct SnapshotPlayer { pub frames: Vec, } +/// Combined struct for single-pass deserialization (avoids cloning the entire JSON) +#[derive(Deserialize)] +struct SnapshotFile { + #[serde(flatten)] + header: SnapshotHeader, + #[serde(default)] + frames: Vec, +} + impl SnapshotPlayer { pub fn load(path: &str) -> Result { let contents = fs::read_to_string(path) .with_context(|| format!("Failed to read snapshot file: {}", path))?; - let value: serde_json::Value = serde_json::from_str(&contents) - .with_context(|| format!("Failed to parse snapshot JSON: {}", path))?; - - let header: SnapshotHeader = serde_json::from_value(value.clone()) - .with_context(|| format!("Failed to deserialize snapshot header: {}", path))?; + let file: SnapshotFile = serde_json::from_str(&contents) + .with_context(|| format!("Failed to parse snapshot file: {}", path))?; - if header.version != 1 { + if file.header.version != 1 { anyhow::bail!( "Unsupported snapshot version {} in {}. This CLI supports version 1.", - header.version, + file.header.version, path ); } - let frames: Vec = match value.get("frames") { - Some(v) => serde_json::from_value(v.clone()) - .with_context(|| format!("Failed to deserialize frames in {}", path))?, - None => { - eprintln!("Warning: snapshot file {} has no 'frames' key — replaying 0 frames.", path); - Vec::new() - } + let frames = if file.frames.is_empty() { + eprintln!("Warning: snapshot file {} has no 'frames' key — replaying 0 frames.", path); + file.frames + } else { + file.frames }; eprintln!( "Loaded snapshot: {} frames, {:.1}s, view={}, captured={}", frames.len(), - header.duration_ms as f64 / 1000.0, - header.view, - header.captured_at, + file.header.duration_ms as f64 / 1000.0, + file.header.view, + file.header.captured_at, ); - Ok(Self { header, frames }) + Ok(Self { header: file.header, frames }) } } From 758f870a151994aad2424180cec3d98a22aeb3aa Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:20:25 +0000 Subject: [PATCH 59/97] fix: NotRegex returns true for absent fields, consistent with NotEq MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously !~ returned false when the field was missing, while != returned true — inconsistent semantics. Now both negative operators treat absent fields as "does not match", which is the intuitive behavior for exclusion filters. --- cli/src/commands/stream/filter.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/commands/stream/filter.rs b/cli/src/commands/stream/filter.rs index 39dba0c1..137b1738 100644 --- a/cli/src/commands/stream/filter.rs +++ b/cli/src/commands/stream/filter.rs @@ -69,7 +69,7 @@ impl Predicate { .is_some_and(|s| re.is_match(s)), FilterOp::NotRegex(re) => match resolved.and_then(|v| v.as_str()) { Some(s) => !re.is_match(s), - None => false, // field absent or non-string → does not match + None => true, // absent/non-string: "does not match regex" — consistent with NotEq }, } } From 9b64a7ad3fc31acb9083d571625375d0657ea334 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:40:05 +0000 Subject: [PATCH 60/97] fix: use VecDeque for raw_frames to avoid O(n) drain from front Vec::drain(0..500) shifts all remaining elements. VecDeque gives O(1) pop_front and maintains exactly 1000 entries instead of oscillating. --- cli/src/commands/stream/tui/app.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index 3f8e610d..417166c9 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -1,7 +1,7 @@ use hyperstack_sdk::{parse_snapshot_entities, Frame, Operation}; use ratatui::widgets::ListState; use serde_json::Value; -use std::collections::HashSet; +use std::collections::{HashSet, VecDeque}; use crate::commands::stream::snapshot::SnapshotRecorder; use crate::commands::stream::store::EntityStore; @@ -65,7 +65,7 @@ pub struct App { pub pending_g: bool, pub list_state: ListState, store: EntityStore, - raw_frames: Vec<(std::time::Instant, Frame)>, + raw_frames: VecDeque<(std::time::Instant, Frame)>, stream_start: std::time::Instant, } @@ -93,7 +93,7 @@ impl App { pending_g: false, list_state: ListState::default().with_selected(Some(0)), store: EntityStore::new(), - raw_frames: Vec::new(), + raw_frames: VecDeque::new(), stream_start: std::time::Instant::now(), } } @@ -149,9 +149,9 @@ impl App { } } - self.raw_frames.push((std::time::Instant::now(), raw_frame)); - if self.raw_frames.len() > 1000 { - self.raw_frames.drain(0..500); + self.raw_frames.push_back((std::time::Instant::now(), raw_frame)); + while self.raw_frames.len() > 1000 { + self.raw_frames.pop_front(); } } From 2355eb6a8bc563fe95bce5ed8dea197fedfe7d8d Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:43:51 +0000 Subject: [PATCH 61/97] fix: remove mouse capture from TUI to restore terminal copy-paste Mouse capture was enabled but mouse events were never handled, only preventing users from selecting/copying text in their terminal. --- cli/src/commands/stream/tui/mod.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index df7d97a0..8a44c593 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -3,7 +3,7 @@ mod ui; use anyhow::{Context, Result}; use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, + event::{self, Event, KeyCode, KeyModifiers}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; @@ -82,14 +82,14 @@ pub async fn run_tui(url: String, view: &str, args: &StreamArgs) -> Result<()> { let original_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |panic_info| { let _ = disable_raw_mode(); - let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture); + let _ = execute!(io::stdout(), LeaveAlternateScreen); original_hook(panic_info); })); enable_raw_mode()?; let terminal_setup = || -> Result>> { let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); Ok(Terminal::new(backend)?) }; @@ -112,7 +112,6 @@ pub async fn run_tui(url: String, view: &str, args: &StreamArgs) -> Result<()> { let _ = execute!( terminal.backend_mut(), LeaveAlternateScreen, - DisableMouseCapture, ); let _ = terminal.show_cursor(); From db3519d7d4339aa1ec9ba6d763a15e678f6bf193 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:45:07 +0000 Subject: [PATCH 62/97] refactor: extract snapshot_complete emission into helper function The snapshot_complete NoDna check was duplicated verbatim in both the binary and text WebSocket message handlers. Now uses a shared maybe_emit_snapshot_complete() function. --- cli/src/commands/stream/client.rs | 45 ++++++++++++++++--------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index f0803dba..555bcf95 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -125,17 +125,7 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { } let was_snapshot = frame.is_snapshot(); if was_snapshot { received_snapshot = true; } - // Emit snapshot_complete BEFORE the first live frame - if !was_snapshot && received_snapshot && !snapshot_complete { - snapshot_complete = true; - if let OutputMode::NoDna = state.output_mode { - output::emit_no_dna_event( - "snapshot_complete", view, - &serde_json::json!({"entity_count": state.entity_count}), - state.update_count, state.entity_count, - )?; - } - } + maybe_emit_snapshot_complete(&state, view, &mut snapshot_complete, received_snapshot, was_snapshot)?; if process_frame(frame, view, &mut state)? { break; } @@ -160,17 +150,7 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { Ok(frame) => { let was_snapshot = frame.is_snapshot(); if was_snapshot { received_snapshot = true; } - // Emit snapshot_complete BEFORE the first live frame - if !was_snapshot && received_snapshot && !snapshot_complete { - snapshot_complete = true; - if let OutputMode::NoDna = state.output_mode { - output::emit_no_dna_event( - "snapshot_complete", view, - &serde_json::json!({"entity_count": state.entity_count}), - state.update_count, state.entity_count, - )?; - } - } + maybe_emit_snapshot_complete(&state, view, &mut snapshot_complete, received_snapshot, was_snapshot)?; if process_frame(frame, view, &mut state)? { break; } @@ -324,6 +304,27 @@ fn output_history_if_requested(state: &StreamState, args: &StreamArgs) -> Result Ok(()) } +/// Emit snapshot_complete NoDna event if transitioning from snapshot to live frames. +fn maybe_emit_snapshot_complete( + state: &StreamState, + view: &str, + snapshot_complete: &mut bool, + received_snapshot: bool, + was_snapshot: bool, +) -> Result<()> { + if !was_snapshot && received_snapshot && !*snapshot_complete { + *snapshot_complete = true; + if let OutputMode::NoDna = state.output_mode { + output::emit_no_dna_event( + "snapshot_complete", view, + &serde_json::json!({"entity_count": state.entity_count}), + state.update_count, state.entity_count, + )?; + } + } + Ok(()) +} + /// Process a frame. Returns true if the stream should end (--first matched). fn process_frame( frame: Frame, From 44ecc5a53296437307afae860455b4bf66dde2c6 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:45:59 +0000 Subject: [PATCH 63/97] fix: TUI deep search also matches JSON field names, not just values Typing /symbol now finds entities with a "symbol" field, not just entities where "symbol" appears as a value. --- cli/src/commands/stream/tui/app.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index 417166c9..d9d9eda5 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -427,7 +427,9 @@ fn value_contains_str(value: &Value, needle: &str) -> bool { s.contains(needle) } Value::Object(map) => { - map.values().any(|v| value_contains_str(v, needle)) + map.iter().any(|(k, v)| { + k.to_lowercase().contains(needle) || value_contains_str(v, needle) + }) } Value::Array(arr) => { arr.iter().any(|v| value_contains_str(v, needle)) From 4a4ba173bffee0bda224a42105f0b08f7f174a43 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 05:46:43 +0000 Subject: [PATCH 64/97] fix: simplify snapshot empty-frames warning (both branches were identical) --- cli/src/commands/stream/snapshot.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/cli/src/commands/stream/snapshot.rs b/cli/src/commands/stream/snapshot.rs index eb6b7d34..67b3f58d 100644 --- a/cli/src/commands/stream/snapshot.rs +++ b/cli/src/commands/stream/snapshot.rs @@ -119,12 +119,10 @@ impl SnapshotPlayer { ); } - let frames = if file.frames.is_empty() { + if file.frames.is_empty() { eprintln!("Warning: snapshot file {} has no 'frames' key — replaying 0 frames.", path); - file.frames - } else { - file.frames - }; + } + let frames = file.frames; eprintln!( "Loaded snapshot: {} frames, {:.1}s, view={}, captured={}", From 2592b8175b9170f4526aaa515086751ac06cf4c9 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:05:59 +0000 Subject: [PATCH 65/97] fix: use try_send for TUI frame channel to prevent blocking when paused When paused, frames accumulate in the bounded channel. The blocking send() prevented pings and shutdown from firing. Now uses try_send() which drops frames when the channel is full (acceptable while paused). --- cli/src/commands/stream/tui/mod.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index 8a44c593..9b5e6f25 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -50,16 +50,13 @@ pub async fn run_tui(url: String, view: &str, args: &StreamArgs) -> Result<()> { match msg { Some(Ok(Message::Binary(bytes))) => { if let Ok(frame) = parse_frame(&bytes) { - if frame_tx.send(frame).await.is_err() { - break; - } + // Non-blocking: drop frames when receiver is paused/full + let _ = frame_tx.try_send(frame); } } Some(Ok(Message::Text(text))) => { if let Ok(frame) = serde_json::from_str::(&text) { - if frame_tx.send(frame).await.is_err() { - break; - } + let _ = frame_tx.try_send(frame); } } Some(Ok(Message::Ping(payload))) => { From 364beea9a3509b8ca37d5241e8f8fe220412e083 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:06:34 +0000 Subject: [PATCH 66/97] fix: reset history_position and scroll_offset on entity delete Stale history_position could cause the detail pane to show nothing after a delete shifted the selected entity. --- cli/src/commands/stream/tui/app.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index d9d9eda5..9c8c3b3a 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -143,6 +143,8 @@ impl App { if self.selected_index >= self.entity_keys.len() && self.selected_index > 0 { self.selected_index -= 1; } + self.history_position = 0; + self.scroll_offset = 0; } Operation::Subscribed => { self.set_status("Subscribed"); From 62d61d7b23d81824bdda4412347f171f085733b3 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:07:17 +0000 Subject: [PATCH 67/97] fix: replay emits snapshot_complete at correct boundary, not after all frames Previously replay() emitted snapshot_complete unconditionally after the entire loop. Now uses maybe_emit_snapshot_complete() to detect the snapshot-to-live transition, matching the live stream behavior. --- cli/src/commands/stream/client.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 555bcf95..f42e0f44 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -224,18 +224,26 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { pub async fn replay(player: SnapshotPlayer, view: &str, args: &StreamArgs) -> Result<()> { let mut state = build_state(args, view, &player.header.url)?; + let mut snapshot_complete = false; + let mut received_snapshot = args.no_snapshot; + for snapshot_frame in &player.frames { + let was_snapshot = snapshot_frame.frame.is_snapshot(); + if was_snapshot { received_snapshot = true; } + maybe_emit_snapshot_complete(&state, view, &mut snapshot_complete, received_snapshot, was_snapshot)?; if process_frame(snapshot_frame.frame.clone(), view, &mut state)? { break; } } if let OutputMode::NoDna = state.output_mode { - output::emit_no_dna_event( - "snapshot_complete", view, - &serde_json::json!({"entity_count": state.entity_count}), - state.update_count, state.entity_count, - )?; + if !snapshot_complete { + output::emit_no_dna_event( + "snapshot_complete", view, + &serde_json::json!({"entity_count": state.entity_count}), + state.update_count, state.entity_count, + )?; + } output::emit_no_dna_event( "disconnected", view, &serde_json::json!(null), From 2e0971e9991fc3a6abe75b7f52189ab876ac6d63 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:08:37 +0000 Subject: [PATCH 68/97] fix: warn eagerly at startup when --history/--at/--diff lack --key Previously the warning was only shown at stream end. Now warns immediately so users know the store is allocated but won't produce output without --key. --- cli/src/commands/stream/client.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index f42e0f44..4f29146a 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -48,6 +48,9 @@ fn build_state(args: &StreamArgs, view: &str, url: &str) -> Result let recorder = args.save.as_ref().map(|_| SnapshotRecorder::new(view, url)); let use_store = args.history || args.at.is_some() || args.diff; + if use_store && args.key.is_none() { + eprintln!("Warning: --history/--at/--diff require --key; history will not be output."); + } let store = if use_store { Some(EntityStore::new()) } else { From 206cb65fa12f1525cd7bc0556eb15016f11fa4f6 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:31:45 +0000 Subject: [PATCH 69/97] fix: show dropped frame count in TUI header when channel is full When paused, try_send drops frames that exceed the 1000-frame channel. Now tracks the count via an atomic counter and shows a red "Dropped: N" indicator in the header so users know data was lost. --- cli/src/commands/stream/tui/app.rs | 4 +++- cli/src/commands/stream/tui/mod.rs | 17 +++++++++++++---- cli/src/commands/stream/tui/ui.rs | 13 +++++++++++-- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index 9c8c3b3a..c0772425 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -67,10 +67,11 @@ pub struct App { store: EntityStore, raw_frames: VecDeque<(std::time::Instant, Frame)>, stream_start: std::time::Instant, + pub dropped_frames: std::sync::Arc, } impl App { - pub fn new(view: String, url: String) -> Self { + pub fn new(view: String, url: String, dropped_frames: std::sync::Arc) -> Self { Self { view: view.clone(), url: url.clone(), @@ -95,6 +96,7 @@ impl App { store: EntityStore::new(), raw_frames: VecDeque::new(), stream_start: std::time::Instant::now(), + dropped_frames, } } diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index 9b5e6f25..ca4927d6 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -11,6 +11,8 @@ use futures_util::{SinkExt, StreamExt}; use hyperstack_sdk::{parse_frame, ClientMessage, Frame}; use ratatui::{backend::CrosstermBackend, Terminal}; use std::io; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; use tokio::sync::mpsc; use tokio_tungstenite::{connect_async, tungstenite::Message}; @@ -36,6 +38,10 @@ pub async fn run_tui(url: String, view: &str, args: &StreamArgs) -> Result<()> { // Shutdown signal for graceful WebSocket close let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + // Dropped frame counter (shared with WS task) + let dropped_frames = Arc::new(AtomicU64::new(0)); + let dropped_frames_ws = Arc::clone(&dropped_frames); + // Spawn WS reader task let ws_handle = tokio::spawn(async move { let ping_period = std::time::Duration::from_secs(30); @@ -50,13 +56,16 @@ pub async fn run_tui(url: String, view: &str, args: &StreamArgs) -> Result<()> { match msg { Some(Ok(Message::Binary(bytes))) => { if let Ok(frame) = parse_frame(&bytes) { - // Non-blocking: drop frames when receiver is paused/full - let _ = frame_tx.try_send(frame); + if frame_tx.try_send(frame).is_err() { + dropped_frames_ws.fetch_add(1, Ordering::Relaxed); + } } } Some(Ok(Message::Text(text))) => { if let Ok(frame) = serde_json::from_str::(&text) { - let _ = frame_tx.try_send(frame); + if frame_tx.try_send(frame).is_err() { + dropped_frames_ws.fetch_add(1, Ordering::Relaxed); + } } } Some(Ok(Message::Ping(payload))) => { @@ -98,7 +107,7 @@ pub async fn run_tui(url: String, view: &str, args: &StreamArgs) -> Result<()> { } }; - let mut app = App::new(view.to_string(), url.clone()); + let mut app = App::new(view.to_string(), url.clone(), Arc::clone(&dropped_frames)); // Main loop: poll terminal events + receive frames let tick_rate = std::time::Duration::from_millis(50); diff --git a/cli/src/commands/stream/tui/ui.rs b/cli/src/commands/stream/tui/ui.rs index 64460880..a7db38bf 100644 --- a/cli/src/commands/stream/tui/ui.rs +++ b/cli/src/commands/stream/tui/ui.rs @@ -37,7 +37,8 @@ fn draw_header(f: &mut Frame, app: &App, area: Rect) { Span::styled(" LIVE ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)) }; - let header = Line::from(vec![ + let dropped = app.dropped_frames.load(std::sync::atomic::Ordering::Relaxed); + let mut spans = vec![ Span::styled("hs stream ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), Span::styled(&app.view, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), Span::raw(" "), @@ -47,7 +48,15 @@ fn draw_header(f: &mut Frame, app: &App, area: Rect) { format!("Updates: {}", app.update_count), Style::default().fg(Color::DarkGray), ), - ]); + ]; + if dropped > 0 { + spans.push(Span::raw(" ")); + spans.push(Span::styled( + format!("Dropped: {}", dropped), + Style::default().fg(Color::Red), + )); + } + let header = Line::from(spans); f.render_widget(Paragraph::new(header), area); } From 9eb8423296ba7d6c581822d1936f6055c5588c4d Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:33:50 +0000 Subject: [PATCH 70/97] fix: use checked arithmetic for history index to prevent overflow 1 + index and len - 1 - index could overflow on usize::MAX. Now uses checked_add/checked_sub returning None safely. --- cli/src/commands/stream/store.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/stream/store.rs b/cli/src/commands/stream/store.rs index 33ae731c..78af9916 100644 --- a/cli/src/commands/stream/store.rs +++ b/cli/src/commands/stream/store.rs @@ -106,7 +106,7 @@ impl EntityStore { if index >= record.history.len() { return None; } - let actual_idx = record.history.len() - 1 - index; + let actual_idx = record.history.len().checked_sub(index.checked_add(1)?)?; record.history.get(actual_idx) } @@ -118,7 +118,7 @@ impl EntityStore { return None; } - let actual_idx = record.history.len().checked_sub(1 + index)?; + let actual_idx = record.history.len().checked_sub(index.checked_add(1)?)?; let current = &record.history.get(actual_idx)?.state; // If this entry has a raw patch, use it directly From 4e9899502d045e6dbbfd1cc5fde13d7f05094a1e Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:37:52 +0000 Subject: [PATCH 71/97] fix: restore default panic hook after TUI exits The custom panic hook (for terminal restoration) was left installed after run_tui returned. Now drops it on exit so it doesn't interfere with any subsequent code. --- cli/src/commands/stream/tui/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index ca4927d6..0c5b5d7b 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -125,6 +125,9 @@ pub async fn run_tui(url: String, view: &str, args: &StreamArgs) -> Result<()> { let _ = shutdown_tx.send(()); let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ws_handle).await; + // Restore original panic hook (ours is only needed while TUI is active) + let _ = std::panic::take_hook(); + result } From 0bbbe75322f48fa2622f086a650bce8d4e52c7d4 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:42:09 +0000 Subject: [PATCH 72/97] perf: cache filtered_keys result, invalidate on filter change or new frame filtered_keys() did O(n*m) deep JSON scans on every render frame (50ms tick) and was called multiple times per frame. Now caches the result and only recomputes when filter text changes or frames arrive. --- cli/src/commands/stream/tui/app.rs | 34 ++++++++++++++++++++++++------ cli/src/commands/stream/tui/ui.rs | 1 + 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index c0772425..fe3802ac 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -68,6 +68,7 @@ pub struct App { raw_frames: VecDeque<(std::time::Instant, Frame)>, stream_start: std::time::Instant, pub dropped_frames: std::sync::Arc, + filtered_cache: Option>, } impl App { @@ -97,10 +98,17 @@ impl App { raw_frames: VecDeque::new(), stream_start: std::time::Instant::now(), dropped_frames, + filtered_cache: None, } } + fn invalidate_filter_cache(&mut self) { + self.filtered_cache = None; + } + pub fn apply_frame(&mut self, frame: Frame) { + self.invalidate_filter_cache(); + // Always collect raw frames so toggling on shows recent data let raw_frame = frame.clone(); let op = frame.operation(); @@ -168,6 +176,7 @@ impl App { } pub fn handle_action(&mut self, action: TuiAction) { + self.ensure_filtered_cache(); // Reset pending_g for any action that isn't GotoTop (gg handled in mod.rs) match &action { TuiAction::GotoTop => {} @@ -269,10 +278,12 @@ impl App { } TuiAction::FilterChar(c) => { self.filter_text.push(c); + self.invalidate_filter_cache(); self.clamp_selection(); } TuiAction::FilterBackspace => { self.filter_text.pop(); + self.invalidate_filter_cache(); self.clamp_selection(); } TuiAction::GotoTop => { @@ -325,6 +336,7 @@ impl App { } fn clamp_selection(&mut self) { + self.ensure_filtered_cache(); let count = self.filtered_keys().len(); if count == 0 { self.selected_index = 0; @@ -397,27 +409,35 @@ impl App { self.status_time = std::time::Instant::now(); } - pub fn filtered_keys(&self) -> Vec<&str> { - if self.filter_text.is_empty() { - self.entity_keys.iter().map(|s| s.as_str()).collect() + /// Returns cached filtered keys. Call `ensure_filtered_cache()` before this. + pub fn filtered_keys(&self) -> &[String] { + self.filtered_cache.as_deref().unwrap_or(&[]) + } + + /// Rebuild the filter cache if invalidated. + pub fn ensure_filtered_cache(&mut self) { + if self.filtered_cache.is_some() { + return; + } + let result = if self.filter_text.is_empty() { + self.entity_keys.clone() } else { let lower = self.filter_text.to_lowercase(); self.entity_keys .iter() .filter(|k| { - // Match on key itself if k.to_lowercase().contains(&lower) { return true; } - // Match on any value inside the entity data if let Some(record) = self.store.get(k) { return value_contains_str(&record.current, &lower); } false }) - .map(|s| s.as_str()) + .cloned() .collect() - } + }; + self.filtered_cache = Some(result); } } diff --git a/cli/src/commands/stream/tui/ui.rs b/cli/src/commands/stream/tui/ui.rs index a7db38bf..05b35a17 100644 --- a/cli/src/commands/stream/tui/ui.rs +++ b/cli/src/commands/stream/tui/ui.rs @@ -9,6 +9,7 @@ use ratatui::{ use super::app::{App, ViewMode}; pub fn draw(f: &mut Frame, app: &mut App) { + app.ensure_filtered_cache(); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ From bf56841bee3dfb7e3bd42d5b2227f11983055ee0 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:45:00 +0000 Subject: [PATCH 73/97] test: add coverage for !=, >=, <=, !~ operators and two-char precedence 5 new tests covering the four previously untested operators and verifying two-char operators take precedence over single-char prefixes. --- cli/src/commands/stream/filter.rs | 46 +++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/cli/src/commands/stream/filter.rs b/cli/src/commands/stream/filter.rs index 137b1738..90b5d47f 100644 --- a/cli/src/commands/stream/filter.rs +++ b/cli/src/commands/stream/filter.rs @@ -323,4 +323,50 @@ mod tests { let result = select_fields(&v, &fields); assert_eq!(result, json!({"a.id": 1, "b.id": 2})); } + + #[test] + fn test_not_eq() { + let f = Filter::parse(&["name!=alice".to_string()]).unwrap(); + assert!(!f.matches(&json!({"name": "alice"}))); + assert!(f.matches(&json!({"name": "bob"}))); + // Absent field: != should return true + assert!(f.matches(&json!({"age": 30}))); + } + + #[test] + fn test_gte() { + let f = Filter::parse(&["score>=100".to_string()]).unwrap(); + assert!(f.matches(&json!({"score": 100}))); + assert!(f.matches(&json!({"score": 150}))); + assert!(!f.matches(&json!({"score": 99}))); + } + + #[test] + fn test_lte() { + let f = Filter::parse(&["score<=100".to_string()]).unwrap(); + assert!(f.matches(&json!({"score": 100}))); + assert!(f.matches(&json!({"score": 50}))); + assert!(!f.matches(&json!({"score": 101}))); + } + + #[test] + fn test_not_regex() { + let f = Filter::parse(&["name!~^ali".to_string()]).unwrap(); + assert!(!f.matches(&json!({"name": "alice"}))); + assert!(f.matches(&json!({"name": "bob"}))); + // Absent field: !~ should return true (consistent with !=) + assert!(f.matches(&json!({"age": 30}))); + } + + #[test] + fn test_two_char_operator_precedence() { + // Ensure >= is not parsed as > with value "=100" + let f = Filter::parse(&["score>=100".to_string()]).unwrap(); + assert!(f.matches(&json!({"score": 100}))); + + // Ensure != is not parsed as ! with something else + let f = Filter::parse(&["name!=x".to_string()]).unwrap(); + assert!(f.matches(&json!({"name": "y"}))); + assert!(!f.matches(&json!({"name": "x"}))); + } } From fd6da5975104a54a661f02489d0ea39e03a86e1b Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:56:50 +0000 Subject: [PATCH 74/97] fix: always attempt all terminal cleanup steps on TUI exit - LeaveAlternateScreen now runs on Terminal::new failure - Original panic hook properly restored via Arc on normal exit (previously lost when moved into the closure) --- cli/src/commands/stream/tui/mod.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index 0c5b5d7b..fa99000d 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -84,12 +84,18 @@ pub async fn run_tui(url: String, view: &str, args: &StreamArgs) -> Result<()> { } }); - // Setup terminal with panic hook to restore on crash - let original_hook = std::panic::take_hook(); + // Setup terminal with panic hook to restore on crash. + // We store the original hook in a Mutex so we can reclaim it on normal exit. + let original_hook = Arc::new(std::sync::Mutex::new(Some(std::panic::take_hook()))); + let hook_clone = Arc::clone(&original_hook); std::panic::set_hook(Box::new(move |panic_info| { let _ = disable_raw_mode(); let _ = execute!(io::stdout(), LeaveAlternateScreen); - original_hook(panic_info); + if let Ok(guard) = hook_clone.lock() { + if let Some(ref orig) = *guard { + orig(panic_info); + } + } })); enable_raw_mode()?; @@ -103,6 +109,7 @@ pub async fn run_tui(url: String, view: &str, args: &StreamArgs) -> Result<()> { Ok(t) => t, Err(e) => { let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen); return Err(e); } }; @@ -126,7 +133,12 @@ pub async fn run_tui(url: String, view: &str, args: &StreamArgs) -> Result<()> { let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ws_handle).await; // Restore original panic hook (ours is only needed while TUI is active) - let _ = std::panic::take_hook(); + let _ = std::panic::take_hook(); // drop our TUI hook + if let Ok(mut guard) = original_hook.lock() { + if let Some(hook) = guard.take() { + std::panic::set_hook(hook); + } + } result } From 31f392a87690ab230f926495375b730d574a48a3 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:57:23 +0000 Subject: [PATCH 75/97] fix: validate WebSocket URL scheme for all resolved URLs, not just --url URLs from hyperstack.toml (via --stack or auto-match) were not validated, producing cryptic tokio-tungstenite errors on http:// URLs. Extracted validate_ws_url() helper applied to all code paths. --- cli/src/commands/stream/mod.rs | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/cli/src/commands/stream/mod.rs b/cli/src/commands/stream/mod.rs index d4c794f9..e3c51712 100644 --- a/cli/src/commands/stream/mod.rs +++ b/cli/src/commands/stream/mod.rs @@ -177,15 +177,20 @@ pub fn build_subscription(view: &str, args: &StreamArgs) -> Subscription { sub } +fn validate_ws_url(url: &str) -> Result<()> { + if !url.starts_with("ws://") && !url.starts_with("wss://") { + bail!( + "Invalid URL scheme. Expected ws:// or wss://, got: {}", + url + ); + } + Ok(()) +} + fn resolve_url(args: &StreamArgs, config_path: &str, view: &str) -> Result { // 1. Explicit --url if let Some(url) = &args.url { - if !url.starts_with("ws://") && !url.starts_with("wss://") { - bail!( - "Invalid URL scheme. Expected ws:// or wss://, got: {}", - url - ); - } + validate_ws_url(url)?; return Ok(url.clone()); } @@ -196,6 +201,7 @@ fn resolve_url(args: &StreamArgs, config_path: &str, view: &str) -> Result Result Result Date: Tue, 24 Mar 2026 06:57:40 +0000 Subject: [PATCH 76/97] fix: debug_assert on filtered_keys() to catch missing ensure_filtered_cache Prevents silent empty results if a future caller forgets to ensure the cache. Falls back gracefully in release builds. --- cli/src/commands/stream/tui/app.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index fe3802ac..dfa10acd 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -409,8 +409,10 @@ impl App { self.status_time = std::time::Instant::now(); } - /// Returns cached filtered keys. Call `ensure_filtered_cache()` before this. + /// Returns cached filtered keys. + /// Panics in debug builds if `ensure_filtered_cache()` was not called first. pub fn filtered_keys(&self) -> &[String] { + debug_assert!(self.filtered_cache.is_some(), "filtered_keys() called without ensure_filtered_cache()"); self.filtered_cache.as_deref().unwrap_or(&[]) } From 369e630159f2c9cf2eb35905cfa0ba1829cdd8ed Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 07:41:20 +0000 Subject: [PATCH 77/97] =?UTF-8?q?fix:=20snapshot=20duration=20from=20frame?= =?UTF-8?q?=20timestamps,=20normalize=20create=E2=86=92upsert=20in=20--ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TUI snapshot duration_ms now computed from first/last frame ts instead of recorder start_time which was near-zero - --ops upsert now also matches create frames (semantically identical) - Document panic hook limitation and history() ordering convention --- cli/src/commands/stream/client.rs | 9 +++++++-- cli/src/commands/stream/snapshot.rs | 7 ++++++- cli/src/commands/stream/store.rs | 1 + cli/src/commands/stream/tui/mod.rs | 4 +++- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 4f29146a..b6b67183 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -354,8 +354,13 @@ fn process_frame( // for entity state — just suppress their output) let ops_allowed = match &state.allowed_ops { Some(allowed) => { - let effective_op = if op == Operation::Snapshot { "snapshot" } else { &op_str.to_lowercase() }; - allowed.contains(effective_op) + // Normalize create → upsert since they're semantically identical + let effective_op = match op { + Operation::Snapshot => "snapshot".to_string(), + Operation::Create => "upsert".to_string(), + _ => op_str.to_lowercase(), + }; + allowed.contains(effective_op.as_str()) } None => true, }; diff --git a/cli/src/commands/stream/snapshot.rs b/cli/src/commands/stream/snapshot.rs index 67b3f58d..7fd13ca5 100644 --- a/cli/src/commands/stream/snapshot.rs +++ b/cli/src/commands/stream/snapshot.rs @@ -55,7 +55,12 @@ impl SnapshotRecorder { } pub fn save(&self, path: &str) -> Result<()> { - let duration_ms = self.start_time.elapsed().as_millis() as u64; + // Compute duration from frame timestamps (first to last), falling back to elapsed + let duration_ms = if self.frames.len() >= 2 { + self.frames.last().unwrap().ts - self.frames.first().unwrap().ts + } else { + self.start_time.elapsed().as_millis() as u64 + }; let header = SnapshotHeader { version: 1, view: self.view.clone(), diff --git a/cli/src/commands/stream/store.rs b/cli/src/commands/stream/store.rs index 78af9916..190af74e 100644 --- a/cli/src/commands/stream/store.rs +++ b/cli/src/commands/stream/store.rs @@ -150,6 +150,7 @@ impl EntityStore { } /// Get the full history for an entity as a JSON array. + /// Entries are ordered newest-first. The `index` field matches `at(key, index)`. pub fn history(&self, key: &str) -> Option { let record = self.entities.get(key)?; let entries: Vec = record diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index fa99000d..893e38f5 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -132,7 +132,9 @@ pub async fn run_tui(url: String, view: &str, args: &StreamArgs) -> Result<()> { let _ = shutdown_tx.send(()); let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ws_handle).await; - // Restore original panic hook (ours is only needed while TUI is active) + // Restore original panic hook (ours is only needed while TUI is active). + // Note: if run_loop panics, this block is unreachable and the TUI hook stays + // installed. This is acceptable since the process terminates on panic anyway. let _ = std::panic::take_hook(); // drop our TUI hook if let Ok(mut guard) = original_hook.lock() { if let Some(hook) = guard.take() { From c74f88d803cfd737ff5aeb898a67f12017d4468d Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 07:58:33 +0000 Subject: [PATCH 78/97] fix: pending_g, delete history retention, snapshot cap, entity_count - Reset pending_g after GotoTop (vim gg required double-press again) - Delete retains entity history with tombstone for post-stream analysis - SnapshotRecorder capped at 100k frames with warning - entity_count updated after snapshot loop, not per-iteration --- cli/src/commands/stream/client.rs | 3 ++- cli/src/commands/stream/snapshot.rs | 12 ++++++++++++ cli/src/commands/stream/store.rs | 22 +++++++++++++++++++--- cli/src/commands/stream/tui/app.rs | 7 ++----- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index b6b67183..8051d8e5 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -393,11 +393,12 @@ fn process_frame( if let Some(store) = &mut state.store { store.upsert(&entity.key, entity.data.clone(), "snapshot", None); } - state.entity_count = state.entities.len() as u64; if ops_allowed && emit_entity(state, view, &entity.key, "snapshot", &entity.data)? { + state.entity_count = state.entities.len() as u64; return Ok(true); } } + state.entity_count = state.entities.len() as u64; } Operation::Upsert | Operation::Create => { state.entities.insert(frame.key.clone(), frame.data.clone()); diff --git a/cli/src/commands/stream/snapshot.rs b/cli/src/commands/stream/snapshot.rs index 7fd13ca5..49035a4a 100644 --- a/cli/src/commands/stream/snapshot.rs +++ b/cli/src/commands/stream/snapshot.rs @@ -38,7 +38,19 @@ impl SnapshotRecorder { } } + const MAX_FRAMES: usize = 100_000; + pub fn record(&mut self, frame: &Frame) { + if self.frames.len() == Self::MAX_FRAMES { + eprintln!( + "Warning: snapshot recorder reached {} frames limit. Further frames will be dropped. \ + Use --duration to limit recording time.", + Self::MAX_FRAMES + ); + } + if self.frames.len() >= Self::MAX_FRAMES { + return; + } let ts = self.start_time.elapsed().as_millis() as u64; self.frames.push(SnapshotFrame { ts, diff --git a/cli/src/commands/stream/store.rs b/cli/src/commands/stream/store.rs index 190af74e..bbf5e0ab 100644 --- a/cli/src/commands/stream/store.rs +++ b/cli/src/commands/stream/store.rs @@ -95,9 +95,21 @@ impl EntityStore { &record.current } - /// Remove an entity. + /// Mark an entity as deleted, retaining its history for post-stream analysis. pub fn delete(&mut self, key: &str) { - self.entities.remove(key); + if let Some(record) = self.entities.get_mut(key) { + let deleted_state = serde_json::json!({"_deleted": true}); + record.history.push_back(HistoryEntry { + seq: None, + op: "delete".to_string(), + state: deleted_state.clone(), + patch: None, + }); + record.current = deleted_state; + if record.history.len() > self.max_history { + record.history.pop_front(); + } + } } /// Get entity state at a specific history index (0 = latest). @@ -271,7 +283,11 @@ mod tests { let mut store = EntityStore::new(); store.upsert("k1", json!({"a": 1}), "upsert", None); store.delete("k1"); - assert!(store.get("k1").is_none()); + // Entity is retained with tombstone for history access + let record = store.get("k1").expect("deleted entity should be retained"); + assert_eq!(record.current, json!({"_deleted": true})); + assert_eq!(record.history.len(), 2); // upsert + delete + assert_eq!(record.history.back().unwrap().op, "delete"); } #[test] diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index dfa10acd..4d75c882 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -177,11 +177,8 @@ impl App { pub fn handle_action(&mut self, action: TuiAction) { self.ensure_filtered_cache(); - // Reset pending_g for any action that isn't GotoTop (gg handled in mod.rs) - match &action { - TuiAction::GotoTop => {} - _ => { self.pending_g = false; } - } + // Reset pending_g after every action (including GotoTop) + self.pending_g = false; match action { TuiAction::Quit => {} From 4ae07d35ab78108b5f40243fc56dbc271af45a35 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:10:23 +0000 Subject: [PATCH 79/97] fix: ignore ctrl/alt combos in filter input, add Ctrl+U/Ctrl+W Ctrl+D, Ctrl+U etc were being inserted as literal characters in the filter text. Now Ctrl+U clears the filter line, Ctrl+W deletes the last word, and other control/alt combos are ignored. --- cli/src/commands/stream/tui/app.rs | 18 ++++++++++++++++++ cli/src/commands/stream/tui/mod.rs | 10 ++++++++++ 2 files changed, 28 insertions(+) diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index 4d75c882..1272840f 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -25,6 +25,8 @@ pub enum TuiAction { SaveSnapshot, FilterChar(char), FilterBackspace, + FilterClear, + FilterDeleteWord, // Detail pane scroll ScrollDetailDown, ScrollDetailUp, @@ -283,6 +285,22 @@ impl App { self.invalidate_filter_cache(); self.clamp_selection(); } + TuiAction::FilterClear => { + self.filter_text.clear(); + self.invalidate_filter_cache(); + self.clamp_selection(); + } + TuiAction::FilterDeleteWord => { + // Delete back to previous word boundary (or start) + let trimmed = self.filter_text.trim_end(); + if let Some(pos) = trimmed.rfind(|c: char| c.is_whitespace()) { + self.filter_text.truncate(pos + 1); + } else { + self.filter_text.clear(); + } + self.invalidate_filter_cache(); + self.clamp_selection(); + } TuiAction::GotoTop => { self.pending_count = None; self.selected_index = 0; diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index 893e38f5..5beb3b84 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -177,6 +177,16 @@ async fn run_loop( KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { TuiAction::Quit } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + TuiAction::FilterClear + } + KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => { + TuiAction::FilterDeleteWord + } + // Ignore other control/alt combos — don't insert them as text + KeyCode::Char(_) if key.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { + continue + } KeyCode::Char(c) => TuiAction::FilterChar(c), KeyCode::Backspace => TuiAction::FilterBackspace, _ => continue, From 9e610e20aa7a9f1bce7cf5dec7fc277fe9b961d7 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:28:18 +0000 Subject: [PATCH 80/97] fix: ops create normalization, entity_count timing, atomic snapshot write - --ops create now correctly matches Create frames (normalized to upsert) - entity_count updated per-entity in snapshot loop so NoDna events have accurate counts - record_with_ts respects MAX_FRAMES cap - --load conflicts_with tui at Clap level (cleaner error) - Snapshot writes use tmp+rename for atomicity --- cli/src/commands/stream/client.rs | 9 ++++++--- cli/src/commands/stream/mod.rs | 2 +- cli/src/commands/stream/snapshot.rs | 11 +++++++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 8051d8e5..88809fa4 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -32,7 +32,11 @@ fn build_state(args: &StreamArgs, view: &str, url: &str) -> Result let select_fields = args.select.as_deref().map(filter::parse_select); let allowed_ops = args.ops.as_deref().map(|ops| { ops.split(',') - .map(|s| s.trim().to_lowercase()) + .map(|s| { + let s = s.trim().to_lowercase(); + // Normalize "create" → "upsert" to match op normalization at comparison time + if s == "create" { "upsert".to_string() } else { s } + }) .collect::>() }); @@ -390,15 +394,14 @@ fn process_frame( for entity in snapshot_entities { // Always populate entity state (needed for correct patch merging) state.entities.insert(entity.key.clone(), entity.data.clone()); + state.entity_count = state.entities.len() as u64; if let Some(store) = &mut state.store { store.upsert(&entity.key, entity.data.clone(), "snapshot", None); } if ops_allowed && emit_entity(state, view, &entity.key, "snapshot", &entity.data)? { - state.entity_count = state.entities.len() as u64; return Ok(true); } } - state.entity_count = state.entities.len() as u64; } Operation::Upsert | Operation::Create => { state.entities.insert(frame.key.clone(), frame.data.clone()); diff --git a/cli/src/commands/stream/mod.rs b/cli/src/commands/stream/mod.rs index e3c51712..6c4a1eb0 100644 --- a/cli/src/commands/stream/mod.rs +++ b/cli/src/commands/stream/mod.rs @@ -83,7 +83,7 @@ pub struct StreamArgs { pub duration: Option, /// Replay a previously saved snapshot file instead of connecting live - #[arg(long, conflicts_with = "url")] + #[arg(long, conflicts_with = "url", conflicts_with = "tui")] pub load: Option, /// Show update history for the specified --key entity diff --git a/cli/src/commands/stream/snapshot.rs b/cli/src/commands/stream/snapshot.rs index 49035a4a..e410c405 100644 --- a/cli/src/commands/stream/snapshot.rs +++ b/cli/src/commands/stream/snapshot.rs @@ -60,6 +60,9 @@ impl SnapshotRecorder { #[cfg(feature = "tui")] pub fn record_with_ts(&mut self, frame: &Frame, ts_ms: u64) { + if self.frames.len() >= Self::MAX_FRAMES { + return; + } self.frames.push(SnapshotFrame { ts: ts_ms, frame: frame.clone(), @@ -93,8 +96,12 @@ impl SnapshotRecorder { }); let json = serde_json::to_string_pretty(&output)?; - fs::write(path, json) - .with_context(|| format!("Failed to write snapshot to {}", path))?; + // Atomic write: write to tmp file then rename, so readers never see partial data + let tmp_path = format!("{}.tmp", path); + fs::write(&tmp_path, json) + .with_context(|| format!("Failed to write snapshot to {}", tmp_path))?; + fs::rename(&tmp_path, path) + .with_context(|| format!("Failed to rename snapshot to {}", path))?; eprintln!( "Saved {} frames ({:.1}s) to {}", From 64b308b55dfe9e00e1c4f33a33aa01bbb7579f1e Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:43:33 +0000 Subject: [PATCH 81/97] fix: subscribed frame fallback, atomic write, early validation, filter errors - TUI WS task handles subscribed frames via try_parse_subscribed_frame fallback (previously silently dropped) - Snapshot tmp file placed in same directory as target (avoids EXDEV) - build_state called before connect_async for fast-fail on bad args - Empty field name in --where (e.g. "=value") now returns clear error --- cli/src/commands/stream/client.rs | 5 +++-- cli/src/commands/stream/filter.rs | 10 ++++++++++ cli/src/commands/stream/snapshot.rs | 7 +++++-- cli/src/commands/stream/tui/mod.rs | 25 +++++++++++++++++++++---- 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 88809fa4..6533dfc3 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -77,6 +77,9 @@ fn build_state(args: &StreamArgs, view: &str, url: &str) -> Result } pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { + // Validate args and build state before connecting (fails fast on bad --where regex etc.) + let mut state = build_state(args, view, &url)?; + let (ws, _) = connect_async(&url) .await .with_context(|| format!("Failed to connect to {}", url))?; @@ -94,8 +97,6 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { .await .context("Failed to send subscribe message")?; - let mut state = build_state(args, view, &url)?; - // Ping interval let ping_period = std::time::Duration::from_secs(30); let mut ping_interval = tokio::time::interval_at(tokio::time::Instant::now() + ping_period, ping_period); diff --git a/cli/src/commands/stream/filter.rs b/cli/src/commands/stream/filter.rs index 90b5d47f..33a35ffe 100644 --- a/cli/src/commands/stream/filter.rs +++ b/cli/src/commands/stream/filter.rs @@ -115,6 +115,10 @@ fn as_f64(value: &Value) -> Option { fn parse_predicate(expr: &str) -> Result { let expr = expr.trim(); + if expr.is_empty() { + bail!("Empty filter expression; expected field=value, field>N, field~regex, etc."); + } + // Existence: field? or field!? if let Some(field) = expr.strip_suffix("!?") { return Ok(Predicate { @@ -141,6 +145,9 @@ fn parse_predicate(expr: &str) -> Result { ] { if let Some(idx) = expr.find(op_str) { let field = &expr[..idx]; + if field.is_empty() { + bail!("Missing field name before '{}' in: '{}'", op_str, expr); + } let value = &expr[idx + op_str.len()..]; return Ok(Predicate { path: parse_path(field), @@ -158,6 +165,9 @@ fn parse_predicate(expr: &str) -> Result { ] { if let Some(idx) = expr.find(*op_char) { let field = &expr[..idx]; + if field.is_empty() { + bail!("Missing field name before '{}' in: '{}'", op_char, expr); + } let value = &expr[idx + 1..]; return Ok(Predicate { path: parse_path(field), diff --git a/cli/src/commands/stream/snapshot.rs b/cli/src/commands/stream/snapshot.rs index e410c405..803f4426 100644 --- a/cli/src/commands/stream/snapshot.rs +++ b/cli/src/commands/stream/snapshot.rs @@ -96,8 +96,11 @@ impl SnapshotRecorder { }); let json = serde_json::to_string_pretty(&output)?; - // Atomic write: write to tmp file then rename, so readers never see partial data - let tmp_path = format!("{}.tmp", path); + // Atomic write: write to tmp file in same directory then rename + let dest = std::path::Path::new(path); + let parent = dest.parent().unwrap_or_else(|| std::path::Path::new(".")); + let file_name = dest.file_name().unwrap_or_default(); + let tmp_path = parent.join(format!("{}.tmp", file_name.to_string_lossy())).to_string_lossy().into_owned(); fs::write(&tmp_path, json) .with_context(|| format!("Failed to write snapshot to {}", tmp_path))?; fs::rename(&tmp_path, path) diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index 5beb3b84..b63eee4b 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -8,7 +8,7 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use futures_util::{SinkExt, StreamExt}; -use hyperstack_sdk::{parse_frame, ClientMessage, Frame}; +use hyperstack_sdk::{parse_frame, try_parse_subscribed_frame, ClientMessage, Frame}; use ratatui::{backend::CrosstermBackend, Terminal}; use std::io; use std::sync::atomic::{AtomicU64, Ordering}; @@ -55,9 +55,26 @@ pub async fn run_tui(url: String, view: &str, args: &StreamArgs) -> Result<()> { msg = ws_rx.next() => { match msg { Some(Ok(Message::Binary(bytes))) => { - if let Ok(frame) = parse_frame(&bytes) { - if frame_tx.try_send(frame).is_err() { - dropped_frames_ws.fetch_add(1, Ordering::Relaxed); + match parse_frame(&bytes) { + Ok(frame) => { + if frame_tx.try_send(frame).is_err() { + dropped_frames_ws.fetch_add(1, Ordering::Relaxed); + } + } + Err(_) => { + // Subscribed frames have a different shape (no `entity` field) + if try_parse_subscribed_frame(&bytes).is_some() { + let subscribed = Frame { + mode: hyperstack_sdk::Mode::List, + entity: String::new(), + op: "subscribed".to_string(), + key: String::new(), + data: serde_json::Value::Null, + append: Vec::new(), + seq: None, + }; + let _ = frame_tx.try_send(subscribed); + } } } } From cc5cea8c1b841c711616113e1bf51e82868a34d8 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:19:30 +0000 Subject: [PATCH 82/97] fix: float equality, visible_rows off-by-one, dead code, filename collisions - Float equality uses string match first, then epsilon comparison (avoids f64 rounding issues for decimals like 1.1) - visible_rows subtracts 5 not 6 (header+timeline+status+2 borders) - Remove unreachable --load/--tui runtime check (clap handles it) - Snapshot filenames include milliseconds to prevent same-second collisions --- cli/src/commands/stream/filter.rs | 10 +++++++--- cli/src/commands/stream/mod.rs | 4 +--- cli/src/commands/stream/tui/app.rs | 2 +- cli/src/commands/stream/tui/mod.rs | 3 ++- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/cli/src/commands/stream/filter.rs b/cli/src/commands/stream/filter.rs index 33a35ffe..5d5174ab 100644 --- a/cli/src/commands/stream/filter.rs +++ b/cli/src/commands/stream/filter.rs @@ -87,10 +87,14 @@ fn value_eq(value: &Value, expected: &str) -> bool { match value { Value::String(s) => s == expected, Value::Number(n) => { - if let Ok(expected_n) = expected.parse::() { - n.as_f64() == Some(expected_n) + // Try exact string match first (avoids f64 rounding for e.g. "1.1") + if n.to_string() == expected { + return true; + } + if let (Some(lhs), Ok(rhs)) = (n.as_f64(), expected.parse::()) { + (lhs - rhs).abs() < f64::EPSILON * lhs.abs().max(1.0) } else { - n.to_string() == expected + false } } Value::Bool(b) => { diff --git a/cli/src/commands/stream/mod.rs b/cli/src/commands/stream/mod.rs index 6c4a1eb0..bf5ee517 100644 --- a/cli/src/commands/stream/mod.rs +++ b/cli/src/commands/stream/mod.rs @@ -105,10 +105,8 @@ pub struct StreamArgs { pub fn run(args: StreamArgs, config_path: &str) -> Result<()> { // --load mode: replay from file, no WebSocket needed + // (--load + --tui conflict is enforced by clap at the arg level) if let Some(load_path) = &args.load { - if args.tui { - bail!("--tui is not yet supported with --load. Use non-interactive replay for now."); - } let player = snapshot::SnapshotPlayer::load(load_path)?; let default_view = player.header.view.clone(); let view = args.view.as_deref().unwrap_or(&default_view); diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index 1272840f..12d91f53 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -269,7 +269,7 @@ impl App { let ts_ms = arrival_time.duration_since(self.stream_start).as_millis() as u64; recorder.record_with_ts(frame, ts_ms); } - let filename = format!("hs-stream-{}.json", chrono::Utc::now().format("%Y%m%d-%H%M%S")); + let filename = format!("hs-stream-{}.json", chrono::Utc::now().format("%Y%m%d-%H%M%S%.3f")); match recorder.save(&filename) { Ok(_) => self.set_status(&format!("Saved to {}", filename)), Err(e) => self.set_status(&format!("Save failed: {}", e)), diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index b63eee4b..ac4f9034 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -171,7 +171,8 @@ async fn run_loop( loop { // Update visible rows from terminal size (minus header/timeline/status/borders) let term_size = terminal.size()?; - app.visible_rows = term_size.height.saturating_sub(6) as usize; + // 3 fixed rows (header + timeline + status) + 2 border rows = 5 + app.visible_rows = term_size.height.saturating_sub(5) as usize; terminal.draw(|f| ui::draw(f, app))?; From 38be0c570ec4a4aad5164dcec9cf4b502cb4c106 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:36:36 +0000 Subject: [PATCH 83/97] fix: snapshot recorder limit warning only prints once The warning fired on every call after MAX_FRAMES since len never exceeded the cap. Now uses a limit_warned flag to print once. --- cli/src/commands/stream/snapshot.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/cli/src/commands/stream/snapshot.rs b/cli/src/commands/stream/snapshot.rs index 803f4426..6b0ed06a 100644 --- a/cli/src/commands/stream/snapshot.rs +++ b/cli/src/commands/stream/snapshot.rs @@ -25,6 +25,7 @@ pub struct SnapshotRecorder { url: String, start_time: std::time::Instant, start_timestamp: chrono::DateTime, + limit_warned: bool, } impl SnapshotRecorder { @@ -35,20 +36,22 @@ impl SnapshotRecorder { url: url.to_string(), start_time: std::time::Instant::now(), start_timestamp: chrono::Utc::now(), + limit_warned: false, } } const MAX_FRAMES: usize = 100_000; pub fn record(&mut self, frame: &Frame) { - if self.frames.len() == Self::MAX_FRAMES { - eprintln!( - "Warning: snapshot recorder reached {} frames limit. Further frames will be dropped. \ - Use --duration to limit recording time.", - Self::MAX_FRAMES - ); - } if self.frames.len() >= Self::MAX_FRAMES { + if !self.limit_warned { + eprintln!( + "Warning: snapshot recorder reached {} frames limit. Further frames will be dropped. \ + Use --duration to limit recording time.", + Self::MAX_FRAMES + ); + self.limit_warned = true; + } return; } let ts = self.start_time.elapsed().as_millis() as u64; From f4b652226635f22376095fe7182514649bd22f73 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:50:43 +0000 Subject: [PATCH 84/97] fix: selection drift on delete, reject --raw/--no-dna with --tui, remove premature log - Delete of entity before cursor now shifts selected_index back to preserve which entity the user was looking at - --raw and --no-dna with --tui now bail with clear messages - Removed premature "Subscribing to..." printed before connection --- cli/src/commands/stream/mod.rs | 7 ++++++- cli/src/commands/stream/tui/app.rs | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/cli/src/commands/stream/mod.rs b/cli/src/commands/stream/mod.rs index bf5ee517..4689f9bc 100644 --- a/cli/src/commands/stream/mod.rs +++ b/cli/src/commands/stream/mod.rs @@ -136,6 +136,12 @@ pub fn run(args: StreamArgs, config_path: &str) -> Result<()> { if args.history || args.at.is_some() || args.diff { bail!("--history/--at/--diff are not supported in TUI mode; use h/l keys to browse history."); } + if args.raw { + bail!("--raw is incompatible with TUI mode; omit --tui to use raw output."); + } + if args.no_dna { + bail!("--no-dna is incompatible with TUI mode; omit --tui to use NO_DNA output."); + } #[cfg(feature = "tui")] { return rt.block_on(tui::run_tui(url, view, &args)); @@ -150,7 +156,6 @@ pub fn run(args: StreamArgs, config_path: &str) -> Result<()> { } eprintln!("Connecting to {} ...", url); - eprintln!("Subscribing to {} ...", view); rt.block_on(client::stream(url, view, &args)) } diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index 12d91f53..a3130175 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -148,12 +148,19 @@ impl App { self.update_count += 1; } Operation::Delete => { + let deleted_pos = self.entity_keys.iter().position(|k| k == &frame.key); self.store.delete(&frame.key); self.entity_key_set.remove(&frame.key); self.entity_keys.retain(|k| k != &frame.key); self.update_count += 1; - if self.selected_index >= self.entity_keys.len() && self.selected_index > 0 { - self.selected_index -= 1; + // If deleted entity was before cursor, shift cursor back to preserve selection + if let Some(pos) = deleted_pos { + if pos < self.selected_index && self.selected_index > 0 { + self.selected_index -= 1; + } + } + if self.selected_index >= self.entity_keys.len() && !self.entity_keys.is_empty() { + self.selected_index = self.entity_keys.len() - 1; } self.history_position = 0; self.scroll_offset = 0; @@ -264,6 +271,9 @@ impl App { self.filter_text.clear(); } TuiAction::SaveSnapshot => { + // Note: this does synchronous file I/O on the runtime thread. Acceptable + // because raw_frames is capped at 1000 entries. For larger caps, consider + // spawning onto a blocking thread. let mut recorder = SnapshotRecorder::new(&self.view, &self.url); for (arrival_time, frame) in &self.raw_frames { let ts_ms = arrival_time.duration_since(self.stream_start).as_millis() as u64; From a90d95bda85c0b2400bf161df00f3c3ff947fbd8 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:10:58 +0000 Subject: [PATCH 85/97] fix: NoDna connected event emitted after connect, replay tagged with source - Moved connected event from build_state to after connect_async succeeds, so failed connections don't emit a connected event with no matching disconnected - Replay connected event includes "source": "replay" so consumers can distinguish live vs replay - --load now conflicts_with --duration at clap level --- cli/src/commands/stream/client.rs | 15 ++++++++++++++- cli/src/commands/stream/mod.rs | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 6533dfc3..a5037f2c 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -43,7 +43,6 @@ fn build_state(args: &StreamArgs, view: &str, url: &str) -> Result let output_mode = if args.raw { OutputMode::Raw } else if args.no_dna { - output::emit_no_dna_event("connected", view, &serde_json::json!({"url": url}), 0, 0)?; OutputMode::NoDna } else { OutputMode::Merged @@ -86,6 +85,11 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { eprintln!("Connected."); + // Emit NoDna connected event only after successful WebSocket handshake + if let OutputMode::NoDna = state.output_mode { + output::emit_no_dna_event("connected", view, &serde_json::json!({"url": url}), 0, 0)?; + } + let (mut ws_tx, mut ws_rx) = ws.split(); // Build and send subscription @@ -232,6 +236,15 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { pub async fn replay(player: SnapshotPlayer, view: &str, args: &StreamArgs) -> Result<()> { let mut state = build_state(args, view, &player.header.url)?; + // Emit NoDna connected event with replay source indicator + if let OutputMode::NoDna = state.output_mode { + output::emit_no_dna_event( + "connected", view, + &serde_json::json!({"url": player.header.url, "source": "replay"}), + 0, 0, + )?; + } + let mut snapshot_complete = false; let mut received_snapshot = args.no_snapshot; diff --git a/cli/src/commands/stream/mod.rs b/cli/src/commands/stream/mod.rs index 4689f9bc..4d292596 100644 --- a/cli/src/commands/stream/mod.rs +++ b/cli/src/commands/stream/mod.rs @@ -83,7 +83,7 @@ pub struct StreamArgs { pub duration: Option, /// Replay a previously saved snapshot file instead of connecting live - #[arg(long, conflicts_with = "url", conflicts_with = "tui")] + #[arg(long, conflicts_with = "url", conflicts_with = "tui", conflicts_with = "duration")] pub load: Option, /// Show update history for the specified --key entity From 8dccbb1093eaa3fd42d5c6545e4a0323226b5f01 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:22:44 +0000 Subject: [PATCH 86/97] fix: TUI filter flag rejection, disconnect detection, float eq, WS close - Bail on --where/--select/--ops/--first with --tui (previously ignored) - TUI detects WebSocket disconnect and shows DISCONNECTED in header - Float equality uses exact bitwise comparison after string match (relative epsilon was too loose for large numbers) - Duration expiry sends WebSocket close frame before breaking --- cli/src/commands/stream/client.rs | 1 + cli/src/commands/stream/filter.rs | 3 ++- cli/src/commands/stream/mod.rs | 12 ++++++++++++ cli/src/commands/stream/tui/app.rs | 7 +++++++ cli/src/commands/stream/tui/mod.rs | 11 +++++++++-- cli/src/commands/stream/tui/ui.rs | 4 +++- 6 files changed, 34 insertions(+), 4 deletions(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index a5037f2c..6d856964 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -195,6 +195,7 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { } _ = &mut duration_future => { eprintln!("Duration reached, stopping..."); + let _ = ws_tx.close().await; break; } _ = &mut shutdown => { diff --git a/cli/src/commands/stream/filter.rs b/cli/src/commands/stream/filter.rs index 5d5174ab..0c1c1d55 100644 --- a/cli/src/commands/stream/filter.rs +++ b/cli/src/commands/stream/filter.rs @@ -91,8 +91,9 @@ fn value_eq(value: &Value, expected: &str) -> bool { if n.to_string() == expected { return true; } + // Fallback: exact f64 bitwise equality (string match above handles most cases) if let (Some(lhs), Ok(rhs)) = (n.as_f64(), expected.parse::()) { - (lhs - rhs).abs() < f64::EPSILON * lhs.abs().max(1.0) + lhs == rhs } else { false } diff --git a/cli/src/commands/stream/mod.rs b/cli/src/commands/stream/mod.rs index 4d292596..f623e17e 100644 --- a/cli/src/commands/stream/mod.rs +++ b/cli/src/commands/stream/mod.rs @@ -142,6 +142,18 @@ pub fn run(args: StreamArgs, config_path: &str) -> Result<()> { if args.no_dna { bail!("--no-dna is incompatible with TUI mode; omit --tui to use NO_DNA output."); } + if !args.filters.is_empty() { + bail!("--where is not supported in TUI mode; use '/' inside the TUI to filter."); + } + if args.select.is_some() { + bail!("--select is not supported in TUI mode."); + } + if args.ops.is_some() { + bail!("--ops is not supported in TUI mode."); + } + if args.first { + bail!("--first is not supported in TUI mode."); + } #[cfg(feature = "tui")] { return rt.block_on(tui::run_tui(url, view, &args)); diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index a3130175..b7228709 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -56,6 +56,7 @@ pub struct App { pub show_diff: bool, pub show_raw: bool, pub paused: bool, + pub disconnected: bool, pub filter_input_active: bool, pub filter_text: String, pub status_message: String, @@ -86,6 +87,7 @@ impl App { show_diff: false, show_raw: false, paused: false, + disconnected: false, filter_input_active: false, filter_text: String::new(), status_message: "Connected".to_string(), @@ -434,6 +436,11 @@ impl App { self.status_time = std::time::Instant::now(); } + pub fn set_disconnected(&mut self) { + self.disconnected = true; + self.set_status("Disconnected"); + } + /// Returns cached filtered keys. /// Panics in debug builds if `ensure_filtered_cache()` was not called first. pub fn filtered_keys(&self) -> &[String] { diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index ac4f9034..036af94f 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -179,8 +179,15 @@ async fn run_loop( // Drain available frames (non-blocking). When paused, leave // frames in the channel so they're applied on resume. if !app.paused { - while let Ok(frame) = frame_rx.try_recv() { - app.apply_frame(frame); + loop { + match frame_rx.try_recv() { + Ok(frame) => app.apply_frame(frame), + Err(mpsc::error::TryRecvError::Disconnected) => { + app.set_disconnected(); + break; + } + Err(mpsc::error::TryRecvError::Empty) => break, + } } } diff --git a/cli/src/commands/stream/tui/ui.rs b/cli/src/commands/stream/tui/ui.rs index 05b35a17..29f8c687 100644 --- a/cli/src/commands/stream/tui/ui.rs +++ b/cli/src/commands/stream/tui/ui.rs @@ -32,7 +32,9 @@ pub fn draw(f: &mut Frame, app: &mut App) { } fn draw_header(f: &mut Frame, app: &App, area: Rect) { - let status = if app.paused { + let status = if app.disconnected { + Span::styled(" DISCONNECTED ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)) + } else if app.paused { Span::styled(" PAUSED ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)) } else { Span::styled(" LIVE ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)) From 694529871a3250e4890cd4e91e6bc5d5d0f30537 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:50:34 +0000 Subject: [PATCH 87/97] fix: count line cleanup, Windows rename, document snapshot entity_count - finalize_count() clears the overwriting \r count line before post- stream messages (prevents garbled terminal output) - Snapshot write removes existing destination before rename (Windows compatibility where fs::rename fails if target exists) - Document that NoDna snapshot entity_count is a running tally - Document silent delete filter drop for unseen entities --- cli/src/commands/stream/client.rs | 16 +++++++++++++++- cli/src/commands/stream/output.rs | 8 ++++++-- cli/src/commands/stream/snapshot.rs | 4 ++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index 6d856964..f6821a2a 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -211,6 +211,11 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { recorder.save(save_path)?; } + // Clear the overwriting count line before post-stream output + if state.count_only { + output::finalize_count(); + } + if let OutputMode::NoDna = state.output_mode { // Ensure snapshot_complete is emitted before disconnected if it wasn't already if !snapshot_complete && received_snapshot { @@ -258,6 +263,10 @@ pub async fn replay(player: SnapshotPlayer, view: &str, args: &StreamArgs) -> Re } } + if state.count_only { + output::finalize_count(); + } + if let OutputMode::NoDna = state.output_mode { if !snapshot_complete { output::emit_no_dna_event( @@ -407,7 +416,10 @@ fn process_frame( Operation::Snapshot => { let snapshot_entities = parse_snapshot_entities(&frame.data); for entity in snapshot_entities { - // Always populate entity state (needed for correct patch merging) + // Always populate entity state (needed for correct patch merging). + // entity_count is a running tally — NoDna entity_update events during + // snapshot delivery report the count at that point, not the final total. + // The final count is available in the snapshot_complete event. state.entities.insert(entity.key.clone(), entity.data.clone()); state.entity_count = state.entities.len() as u64; if let Some(store) = &mut state.store { @@ -443,6 +455,8 @@ fn process_frame( } } Operation::Delete => { + // Note: if the entity was never seen (e.g. --no-snapshot), last_state is null + // and field-based --where filters will not match, silently dropping the delete. let last_state = state.entities.remove(&frame.key).unwrap_or(serde_json::json!(null)); if let Some(store) = &mut state.store { store.delete(&frame.key); diff --git a/cli/src/commands/stream/output.rs b/cli/src/commands/stream/output.rs index 531a5dd5..fde18c4e 100644 --- a/cli/src/commands/stream/output.rs +++ b/cli/src/commands/stream/output.rs @@ -54,12 +54,16 @@ pub fn print_delete(view: &str, key: &str) -> Result<()> { /// Print a running update count to stderr (overwrites line). pub fn print_count(count: u64) -> Result<()> { - eprint!("\rUpdates: {}", count); - use std::io::Write; + eprint!("\rUpdates: {} ", count); // trailing spaces clear leftover chars std::io::stderr().flush()?; Ok(()) } +/// Move to a new line after overwriting count display. +pub fn finalize_count() { + eprintln!(); +} + /// Emit a NO_DNA envelope event as a single JSON line to stdout. pub fn emit_no_dna_event( action: &str, diff --git a/cli/src/commands/stream/snapshot.rs b/cli/src/commands/stream/snapshot.rs index 6b0ed06a..6c912ac6 100644 --- a/cli/src/commands/stream/snapshot.rs +++ b/cli/src/commands/stream/snapshot.rs @@ -106,6 +106,10 @@ impl SnapshotRecorder { let tmp_path = parent.join(format!("{}.tmp", file_name.to_string_lossy())).to_string_lossy().into_owned(); fs::write(&tmp_path, json) .with_context(|| format!("Failed to write snapshot to {}", tmp_path))?; + // On Windows, fs::rename fails if destination exists; remove it first. + if dest.exists() { + let _ = fs::remove_file(path); + } fs::rename(&tmp_path, path) .with_context(|| format!("Failed to rename snapshot to {}", path))?; From 1d41852bf39ca95591ba446dc4a522c96d955718 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:22:30 +0000 Subject: [PATCH 88/97] fix: scope Windows rename workaround with cfg, clean up tmp on failure - Remove-before-rename only runs on Windows (POSIX rename overwrites) - Propagate remove_file errors instead of silently swallowing them - Clean up tmp file on rename failure before propagating error --- cli/src/commands/stream/snapshot.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/cli/src/commands/stream/snapshot.rs b/cli/src/commands/stream/snapshot.rs index 6c912ac6..47c1a745 100644 --- a/cli/src/commands/stream/snapshot.rs +++ b/cli/src/commands/stream/snapshot.rs @@ -106,12 +106,19 @@ impl SnapshotRecorder { let tmp_path = parent.join(format!("{}.tmp", file_name.to_string_lossy())).to_string_lossy().into_owned(); fs::write(&tmp_path, json) .with_context(|| format!("Failed to write snapshot to {}", tmp_path))?; - // On Windows, fs::rename fails if destination exists; remove it first. + // Attempt remove; if it fails, let rename itself fail with a clear error + // (don't silently swallow remove errors that may mask the true state). + #[cfg(windows)] if dest.exists() { - let _ = fs::remove_file(path); + fs::remove_file(path) + .with_context(|| format!("Failed to remove existing snapshot at {}", path))?; } fs::rename(&tmp_path, path) - .with_context(|| format!("Failed to rename snapshot to {}", path))?; + .map_err(|e| { + // Best-effort cleanup of the tmp file before propagating + let _ = fs::remove_file(&tmp_path); + anyhow::anyhow!("Failed to rename snapshot to {}: {}", path, e) + })?; eprintln!( "Saved {} frames ({:.1}s) to {}", From 7ebddc180ad57121182e8873453462a21dff216c Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:37:52 +0000 Subject: [PATCH 89/97] perf: shared StdoutWriter, single-parse text frames, dedup diff_at lookups - Output functions now use a shared BufWriter held in StreamState instead of acquiring/releasing stdout lock per call - Text WebSocket frames parsed once directly to Frame instead of double- parsing (Value then Frame) - diff_at stores entry in local variable instead of 3 redundant gets - TUI channel buffer increased to 10k for large snapshot batches - Document that filter cache invalidation is per-tick not per-frame --- cli/src/commands/stream/client.rs | 42 ++++++++++----------- cli/src/commands/stream/output.rs | 59 ++++++++++++++++++++---------- cli/src/commands/stream/store.rs | 14 +++---- cli/src/commands/stream/tui/app.rs | 3 ++ cli/src/commands/stream/tui/mod.rs | 4 +- 5 files changed, 74 insertions(+), 48 deletions(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index f6821a2a..d6e04694 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -25,6 +25,7 @@ struct StreamState { update_count: u64, entity_count: u64, recorder: Option, + out: output::StdoutWriter, } fn build_state(args: &StreamArgs, view: &str, url: &str) -> Result { @@ -72,6 +73,7 @@ fn build_state(args: &StreamArgs, view: &str, url: &str) -> Result update_count: 0, entity_count: 0, recorder, + out: output::StdoutWriter::new(), }) } @@ -87,7 +89,7 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { // Emit NoDna connected event only after successful WebSocket handshake if let OutputMode::NoDna = state.output_mode { - output::emit_no_dna_event("connected", view, &serde_json::json!({"url": url}), 0, 0)?; + output::emit_no_dna_event(&mut state.out, "connected", view, &serde_json::json!({"url": url}), 0, 0)?; } let (mut ws_tx, mut ws_rx) = ws.split(); @@ -137,7 +139,7 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { } let was_snapshot = frame.is_snapshot(); if was_snapshot { received_snapshot = true; } - maybe_emit_snapshot_complete(&state, view, &mut snapshot_complete, received_snapshot, was_snapshot)?; + maybe_emit_snapshot_complete(&mut state, view, &mut snapshot_complete, received_snapshot, was_snapshot)?; if process_frame(frame, view, &mut state)? { break; } @@ -152,17 +154,15 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { } } Some(Ok(Message::Text(text))) => { - if let Ok(value) = serde_json::from_str::(&text) { - if value.get("op").and_then(|v| v.as_str()) == Some("subscribed") { + // Single-pass parse: try Frame directly, check for subscribed via operation() + match serde_json::from_str::(&text) { + Ok(frame) if frame.operation() == Operation::Subscribed => { eprintln!("Subscribed to {}", view); - continue; } - } - match serde_json::from_str::(&text) { Ok(frame) => { let was_snapshot = frame.is_snapshot(); if was_snapshot { received_snapshot = true; } - maybe_emit_snapshot_complete(&state, view, &mut snapshot_complete, received_snapshot, was_snapshot)?; + maybe_emit_snapshot_complete(&mut state, view, &mut snapshot_complete, received_snapshot, was_snapshot)?; if process_frame(frame, view, &mut state)? { break; } @@ -219,13 +219,13 @@ pub async fn stream(url: String, view: &str, args: &StreamArgs) -> Result<()> { if let OutputMode::NoDna = state.output_mode { // Ensure snapshot_complete is emitted before disconnected if it wasn't already if !snapshot_complete && received_snapshot { - output::emit_no_dna_event( + output::emit_no_dna_event(&mut state.out, "snapshot_complete", view, &serde_json::json!({"entity_count": state.entity_count}), state.update_count, state.entity_count, )?; } - output::emit_no_dna_event( + output::emit_no_dna_event(&mut state.out, "disconnected", view, &serde_json::json!(null), state.update_count, state.entity_count, @@ -244,7 +244,7 @@ pub async fn replay(player: SnapshotPlayer, view: &str, args: &StreamArgs) -> Re // Emit NoDna connected event with replay source indicator if let OutputMode::NoDna = state.output_mode { - output::emit_no_dna_event( + output::emit_no_dna_event(&mut state.out, "connected", view, &serde_json::json!({"url": player.header.url, "source": "replay"}), 0, 0, @@ -257,7 +257,7 @@ pub async fn replay(player: SnapshotPlayer, view: &str, args: &StreamArgs) -> Re for snapshot_frame in &player.frames { let was_snapshot = snapshot_frame.frame.is_snapshot(); if was_snapshot { received_snapshot = true; } - maybe_emit_snapshot_complete(&state, view, &mut snapshot_complete, received_snapshot, was_snapshot)?; + maybe_emit_snapshot_complete(&mut state, view, &mut snapshot_complete, received_snapshot, was_snapshot)?; if process_frame(snapshot_frame.frame.clone(), view, &mut state)? { break; } @@ -269,13 +269,13 @@ pub async fn replay(player: SnapshotPlayer, view: &str, args: &StreamArgs) -> Re if let OutputMode::NoDna = state.output_mode { if !snapshot_complete { - output::emit_no_dna_event( + output::emit_no_dna_event(&mut state.out, "snapshot_complete", view, &serde_json::json!({"entity_count": state.entity_count}), state.update_count, state.entity_count, )?; } - output::emit_no_dna_event( + output::emit_no_dna_event(&mut state.out, "disconnected", view, &serde_json::json!(null), state.update_count, state.entity_count, @@ -345,7 +345,7 @@ fn output_history_if_requested(state: &StreamState, args: &StreamArgs) -> Result /// Emit snapshot_complete NoDna event if transitioning from snapshot to live frames. fn maybe_emit_snapshot_complete( - state: &StreamState, + state: &mut StreamState, view: &str, snapshot_complete: &mut bool, received_snapshot: bool, @@ -354,7 +354,7 @@ fn maybe_emit_snapshot_complete( if !was_snapshot && received_snapshot && !*snapshot_complete { *snapshot_complete = true; if let OutputMode::NoDna = state.output_mode { - output::emit_no_dna_event( + output::emit_no_dna_event(&mut state.out, "snapshot_complete", view, &serde_json::json!({"entity_count": state.entity_count}), state.update_count, state.entity_count, @@ -407,7 +407,7 @@ fn process_frame( if state.count_only { output::print_count(state.update_count)?; } else { - output::print_raw_frame(&frame)?; + output::print_raw_frame(&mut state.out, &frame)?; } return Ok(state.first); } @@ -475,12 +475,12 @@ fn process_frame( output::print_count(state.update_count)?; } else { match state.output_mode { - OutputMode::NoDna => output::emit_no_dna_event( + OutputMode::NoDna => output::emit_no_dna_event(&mut state.out, "entity_update", view, &serde_json::json!({"key": frame.key, "op": "delete", "data": null}), state.update_count, state.entity_count, )?, - _ => output::print_delete(view, &frame.key)?, + _ => output::print_delete(&mut state.out, view, &frame.key)?, } } if state.first { @@ -516,12 +516,12 @@ fn emit_entity( output::print_count(state.update_count)?; } else { match state.output_mode { - OutputMode::NoDna => output::emit_no_dna_event( + OutputMode::NoDna => output::emit_no_dna_event(&mut state.out, "entity_update", view, &serde_json::json!({"key": key, "op": op, "data": output_data}), state.update_count, state.entity_count, )?, - _ => output::print_entity_update(view, key, op, &output_data)?, + _ => output::print_entity_update(&mut state.out, view, key, op, &output_data)?, } } diff --git a/cli/src/commands/stream/output.rs b/cli/src/commands/stream/output.rs index fde18c4e..0d53d02f 100644 --- a/cli/src/commands/stream/output.rs +++ b/cli/src/commands/stream/output.rs @@ -1,6 +1,6 @@ use anyhow::Result; use hyperstack_sdk::Frame; -use std::io::{self, Write}; +use std::io::{self, BufWriter, Write}; pub enum OutputMode { Raw, @@ -8,17 +8,46 @@ pub enum OutputMode { NoDna, } +/// Buffered stdout writer. Holds a single lock for the lifetime of the stream. +/// Flushes on drop. +pub struct StdoutWriter { + inner: BufWriter, +} + +impl StdoutWriter { + pub fn new() -> Self { + Self { + inner: BufWriter::new(io::stdout()), + } + } + + pub fn writeln(&mut self, line: &str) -> Result<()> { + writeln!(self.inner, "{}", line)?; + Ok(()) + } + + #[allow(dead_code)] + pub fn flush(&mut self) -> Result<()> { + self.inner.flush()?; + Ok(()) + } +} + +impl Drop for StdoutWriter { + fn drop(&mut self) { + let _ = self.inner.flush(); + } +} + /// Print a raw WebSocket frame as a single JSON line to stdout. -pub fn print_raw_frame(frame: &Frame) -> Result<()> { +pub fn print_raw_frame(out: &mut StdoutWriter, frame: &Frame) -> Result<()> { let line = serde_json::to_string(frame)?; - let stdout = io::stdout(); - let mut out = stdout.lock(); - writeln!(out, "{}", line)?; - Ok(()) + out.writeln(&line) } /// Print a merged entity update as a single JSON line to stdout. pub fn print_entity_update( + out: &mut StdoutWriter, view: &str, key: &str, op: &str, @@ -31,14 +60,11 @@ pub fn print_entity_update( "data": data, }); let line = serde_json::to_string(&output)?; - let stdout = io::stdout(); - let mut out = stdout.lock(); - writeln!(out, "{}", line)?; - Ok(()) + out.writeln(&line) } /// Print an entity deletion as a single JSON line to stdout. -pub fn print_delete(view: &str, key: &str) -> Result<()> { +pub fn print_delete(out: &mut StdoutWriter, view: &str, key: &str) -> Result<()> { let output = serde_json::json!({ "view": view, "key": key, @@ -46,10 +72,7 @@ pub fn print_delete(view: &str, key: &str) -> Result<()> { "data": null, }); let line = serde_json::to_string(&output)?; - let stdout = io::stdout(); - let mut out = stdout.lock(); - writeln!(out, "{}", line)?; - Ok(()) + out.writeln(&line) } /// Print a running update count to stderr (overwrites line). @@ -66,6 +89,7 @@ pub fn finalize_count() { /// Emit a NO_DNA envelope event as a single JSON line to stdout. pub fn emit_no_dna_event( + out: &mut StdoutWriter, action: &str, view: &str, data: &serde_json::Value, @@ -87,8 +111,5 @@ pub fn emit_no_dna_event( }, }); let line = serde_json::to_string(&output)?; - let stdout = io::stdout(); - let mut out = stdout.lock(); - writeln!(out, "{}", line)?; - Ok(()) + out.writeln(&line) } diff --git a/cli/src/commands/stream/store.rs b/cli/src/commands/stream/store.rs index bbf5e0ab..5f31fc01 100644 --- a/cli/src/commands/stream/store.rs +++ b/cli/src/commands/stream/store.rs @@ -131,16 +131,16 @@ impl EntityStore { } let actual_idx = record.history.len().checked_sub(index.checked_add(1)?)?; - let current = &record.history.get(actual_idx)?.state; + let entry = record.history.get(actual_idx)?; // If this entry has a raw patch, use it directly - if let Some(patch) = &record.history.get(actual_idx)?.patch { + if let Some(patch) = &entry.patch { return Some(serde_json::json!({ - "op": record.history.get(actual_idx)?.op, + "op": entry.op, "index": index, "total": record.history.len(), "patch": patch, - "state": current, + "state": entry.state, })); } @@ -151,13 +151,13 @@ impl EntityStore { &Value::Null }; - let changes = compute_diff(previous, current); + let changes = compute_diff(previous, &entry.state); Some(serde_json::json!({ - "op": record.history.get(actual_idx)?.op, + "op": entry.op, "index": index, "total": record.history.len(), "changes": changes, - "state": current, + "state": entry.state, })) } diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index b7228709..99858e2b 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -111,6 +111,9 @@ impl App { } pub fn apply_frame(&mut self, frame: Frame) { + // Invalidation is cheap (sets to None). The cache is only rebuilt once per + // render tick in ensure_filtered_cache(), not per-frame, since we drain all + // frames before drawing. self.invalidate_filter_cache(); // Always collect raw frames so toggling on shows recent data diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index 036af94f..233c92f8 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -33,7 +33,9 @@ pub async fn run_tui(url: String, view: &str, args: &StreamArgs) -> Result<()> { ws_tx.send(Message::Text(msg)).await?; // Channel for frames from WS task - let (frame_tx, mut frame_rx) = mpsc::channel::(1000); + // 10k buffer accommodates large snapshot batches during pause. Overflow + // frames are dropped and counted in the "Dropped: N" header indicator. + let (frame_tx, mut frame_rx) = mpsc::channel::(10_000); // Shutdown signal for graceful WebSocket close let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>(); From 97f8a6ba277596dab8b7b8f9c1fe3eb78ffcd4d1 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:59:25 +0000 Subject: [PATCH 90/97] fix: replay snapshot_complete guard, docs for select/diff/colorize, remove dead flush - Replay snapshot_complete now checks received_snapshot (consistent with live stream path) - Document --select flattening behavior in help text - Document compute_diff as shallow top-level only - Document colorize_json_line serde_json assumption - Confirm --first semantics with comment - Remove unused StdoutWriter::flush (Drop impl suffices) --- cli/src/commands/stream/client.rs | 4 +++- cli/src/commands/stream/mod.rs | 3 ++- cli/src/commands/stream/output.rs | 6 ------ cli/src/commands/stream/store.rs | 4 +++- cli/src/commands/stream/tui/ui.rs | 2 ++ 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/cli/src/commands/stream/client.rs b/cli/src/commands/stream/client.rs index d6e04694..d20b3528 100644 --- a/cli/src/commands/stream/client.rs +++ b/cli/src/commands/stream/client.rs @@ -268,7 +268,7 @@ pub async fn replay(player: SnapshotPlayer, view: &str, args: &StreamArgs) -> Re } if let OutputMode::NoDna = state.output_mode { - if !snapshot_complete { + if !snapshot_complete && received_snapshot { output::emit_no_dna_event(&mut state.out, "snapshot_complete", view, &serde_json::json!({"entity_count": state.entity_count}), @@ -425,6 +425,8 @@ fn process_frame( if let Some(store) = &mut state.store { store.upsert(&entity.key, entity.data.clone(), "snapshot", None); } + // --first: exits on the first matching entity (even within a snapshot batch). + // update_count will be 1 in the emitted event, which is correct. if ops_allowed && emit_entity(state, view, &entity.key, "snapshot", &entity.data)? { return Ok(true); } diff --git a/cli/src/commands/stream/mod.rs b/cli/src/commands/stream/mod.rs index f623e17e..956d5a12 100644 --- a/cli/src/commands/stream/mod.rs +++ b/cli/src/commands/stream/mod.rs @@ -42,7 +42,8 @@ pub struct StreamArgs { #[arg(long = "where", value_name = "EXPR")] pub filters: Vec, - /// Select specific fields to output (comma-separated dot paths) + /// Select specific fields to output (comma-separated dot paths). Nested paths are + /// flattened to literal keys, e.g. --select "info.name" outputs {"info.name": "..."} #[arg(long)] pub select: Option, diff --git a/cli/src/commands/stream/output.rs b/cli/src/commands/stream/output.rs index 0d53d02f..19ec7704 100644 --- a/cli/src/commands/stream/output.rs +++ b/cli/src/commands/stream/output.rs @@ -25,12 +25,6 @@ impl StdoutWriter { writeln!(self.inner, "{}", line)?; Ok(()) } - - #[allow(dead_code)] - pub fn flush(&mut self) -> Result<()> { - self.inner.flush()?; - Ok(()) - } } impl Drop for StdoutWriter { diff --git a/cli/src/commands/stream/store.rs b/cli/src/commands/stream/store.rs index 5f31fc01..219eddcb 100644 --- a/cli/src/commands/stream/store.rs +++ b/cli/src/commands/stream/store.rs @@ -184,7 +184,9 @@ impl EntityStore { } } -/// Compute a simple diff between two JSON values. +/// Compute a shallow (top-level only) diff between two JSON values. +/// For nested objects, reports the entire sub-object as changed. Patch operations +/// use the raw patch instead of this diff, so this only affects upsert/snapshot history. fn compute_diff(old: &Value, new: &Value) -> Value { match (old, new) { (Value::Object(old_map), Value::Object(new_map)) => { diff --git a/cli/src/commands/stream/tui/ui.rs b/cli/src/commands/stream/tui/ui.rs index 29f8c687..abcb0540 100644 --- a/cli/src/commands/stream/tui/ui.rs +++ b/cli/src/commands/stream/tui/ui.rs @@ -241,6 +241,8 @@ fn truncate_key(key: &str, max_len: usize) -> String { } } +/// Simple heuristic JSON syntax coloring for serde_json::to_string_pretty output. +/// Assumes keys are always properly quoted/escaped (guaranteed by serde_json). fn colorize_json_line(line: &str) -> Line<'_> { let trimmed = line.trim(); From 0c412fb4f14faf2cd9e496afd214991c4391781a Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:42:56 +0000 Subject: [PATCH 91/97] fix: flush stdout per write, streaming snapshot, key coloring, pending_count cap - StdoutWriter flushes after each writeln (prevents delays on low- throughput streams) - JSON key coloring uses "\": " to avoid matching colons inside keys - Snapshot serialization streams to file via BufWriter instead of building full JSON string in memory - Document --ops snapshot as valid value - Cap pending_count at 99999 to prevent usize overflow --- cli/src/commands/stream/mod.rs | 4 ++- cli/src/commands/stream/output.rs | 1 + cli/src/commands/stream/snapshot.rs | 43 +++++++++++++++++++---------- cli/src/commands/stream/tui/mod.rs | 2 +- cli/src/commands/stream/tui/ui.rs | 4 ++- 5 files changed, 37 insertions(+), 17 deletions(-) diff --git a/cli/src/commands/stream/mod.rs b/cli/src/commands/stream/mod.rs index 956d5a12..afdd3257 100644 --- a/cli/src/commands/stream/mod.rs +++ b/cli/src/commands/stream/mod.rs @@ -51,7 +51,9 @@ pub struct StreamArgs { #[arg(long)] pub first: bool, - /// Filter by operation type (comma-separated: upsert,patch,delete) + /// Filter by operation type (comma-separated: snapshot,upsert,patch,delete). + /// "upsert" also matches "create". Snapshot entities are always tracked for + /// state merging but only emitted when "snapshot" is in the allowed set #[arg(long)] pub ops: Option, diff --git a/cli/src/commands/stream/output.rs b/cli/src/commands/stream/output.rs index 19ec7704..025322a0 100644 --- a/cli/src/commands/stream/output.rs +++ b/cli/src/commands/stream/output.rs @@ -23,6 +23,7 @@ impl StdoutWriter { pub fn writeln(&mut self, line: &str) -> Result<()> { writeln!(self.inner, "{}", line)?; + self.inner.flush()?; Ok(()) } } diff --git a/cli/src/commands/stream/snapshot.rs b/cli/src/commands/stream/snapshot.rs index 47c1a745..585504a9 100644 --- a/cli/src/commands/stream/snapshot.rs +++ b/cli/src/commands/stream/snapshot.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result}; use hyperstack_sdk::Frame; use serde::{Deserialize, Serialize}; use std::fs; +use std::io::{self, Write}; #[derive(Debug, Serialize, Deserialize)] pub struct SnapshotHeader { @@ -88,24 +89,38 @@ impl SnapshotRecorder { frame_count: self.frames.len() as u64, }; - let output = serde_json::json!({ - "version": header.version, - "view": header.view, - "url": header.url, - "captured_at": header.captured_at, - "duration_ms": header.duration_ms, - "frame_count": header.frame_count, - "frames": self.frames, - }); - - let json = serde_json::to_string_pretty(&output)?; - // Atomic write: write to tmp file in same directory then rename + // Stream-serialize to tmp file to avoid holding the entire JSON in memory. let dest = std::path::Path::new(path); let parent = dest.parent().unwrap_or_else(|| std::path::Path::new(".")); let file_name = dest.file_name().unwrap_or_default(); let tmp_path = parent.join(format!("{}.tmp", file_name.to_string_lossy())).to_string_lossy().into_owned(); - fs::write(&tmp_path, json) - .with_context(|| format!("Failed to write snapshot to {}", tmp_path))?; + { + let file = fs::File::create(&tmp_path) + .with_context(|| format!("Failed to create snapshot file: {}", tmp_path))?; + let mut writer = io::BufWriter::new(file); + + // Write header fields + writeln!(writer, "{{")?; + writeln!(writer, " \"version\": {},", header.version)?; + writeln!(writer, " \"view\": {},", serde_json::to_string(&header.view)?)?; + writeln!(writer, " \"url\": {},", serde_json::to_string(&header.url)?)?; + writeln!(writer, " \"captured_at\": {},", serde_json::to_string(&header.captured_at)?)?; + writeln!(writer, " \"duration_ms\": {},", header.duration_ms)?; + writeln!(writer, " \"frame_count\": {},", header.frame_count)?; + + // Stream frames array one entry at a time + writeln!(writer, " \"frames\": [")?; + for (i, frame) in self.frames.iter().enumerate() { + let frame_json = serde_json::to_string(frame)?; + if i > 0 { + writeln!(writer, ",")?; + } + write!(writer, " {}", frame_json)?; + } + writeln!(writer, "\n ]")?; + writeln!(writer, "}}")?; + writer.flush()?; + } // Attempt remove; if it fails, let rename itself fail with a clear error // (don't silently swallow remove errors that may mask the true state). #[cfg(windows)] diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index 233c92f8..28ffa68b 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -225,7 +225,7 @@ async fn run_loop( if c != '0' || app.pending_count.is_some() { let digit = c as usize - '0' as usize; let current = app.pending_count.unwrap_or(0); - app.pending_count = Some(current * 10 + digit); + app.pending_count = Some((current.saturating_mul(10).saturating_add(digit)).min(99_999)); app.pending_g = false; continue; } diff --git a/cli/src/commands/stream/tui/ui.rs b/cli/src/commands/stream/tui/ui.rs index abcb0540..aa96602b 100644 --- a/cli/src/commands/stream/tui/ui.rs +++ b/cli/src/commands/stream/tui/ui.rs @@ -248,7 +248,9 @@ fn colorize_json_line(line: &str) -> Line<'_> { // Key-value lines if trimmed.starts_with('"') { - if let Some(colon_pos) = trimmed.find("\":") { + // Use "\": " (with trailing space) to avoid matching colons inside key names. + // serde_json pretty-print always uses ": " as the key-value separator. + if let Some(colon_pos) = trimmed.find("\": ") { let key_end = colon_pos + 1; let indent = &line[..line.len() - trimmed.len()]; let key = &trimmed[..key_end]; From df044383616c3363428c086f3d2728525bf31f88 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:50:12 +0000 Subject: [PATCH 92/97] feat: context-aware j/k navigation in TUI detail mode In Detail mode (after pressing Enter), j/k now scroll the JSON detail pane instead of navigating between entities. G/gg go to bottom/top of the JSON, Ctrl+d/Ctrl+u do half-page scroll. Arrow keys still navigate entities in both modes as an escape hatch. Press Esc to return to list mode where j/k navigate entities. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/commands/stream/tui/app.rs | 21 +++++++++++++ cli/src/commands/stream/tui/mod.rs | 49 +++++++++++++++++++++++++----- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index 99858e2b..d1fe7cdc 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -30,6 +30,10 @@ pub enum TuiAction { // Detail pane scroll ScrollDetailDown, ScrollDetailUp, + ScrollDetailTop, + ScrollDetailBottom, + ScrollDetailHalfDown, + ScrollDetailHalfUp, // Vim motions GotoTop, GotoBottom, @@ -204,6 +208,23 @@ impl App { let n = self.take_count(); self.scroll_offset = self.scroll_offset.saturating_sub(n as u16); } + TuiAction::ScrollDetailTop => { + self.pending_count = None; + self.scroll_offset = 0; + } + TuiAction::ScrollDetailBottom => { + self.pending_count = None; + // Set to a large value; rendering will clamp naturally + self.scroll_offset = u16::MAX; + } + TuiAction::ScrollDetailHalfDown => { + let half = (self.visible_rows / 2).max(1); + self.scroll_offset = self.scroll_offset.saturating_add(half as u16); + } + TuiAction::ScrollDetailHalfUp => { + let half = (self.visible_rows / 2).max(1); + self.scroll_offset = self.scroll_offset.saturating_sub(half as u16); + } TuiAction::NextEntity => { let n = self.take_count(); let count = self.filtered_keys().len(); diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index 28ffa68b..9b0a5888 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use tokio::sync::mpsc; use tokio_tungstenite::{connect_async, tungstenite::Message}; -use self::app::{App, TuiAction}; +use self::app::{App, TuiAction, ViewMode}; use super::StreamArgs; pub async fn run_tui(url: String, view: &str, args: &StreamArgs) -> Result<()> { @@ -236,13 +236,38 @@ async fn run_loop( KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { TuiAction::Quit } - KeyCode::Down | KeyCode::Char('j') => TuiAction::NextEntity, - KeyCode::Up | KeyCode::Char('k') => TuiAction::PrevEntity, - KeyCode::Char('G') => TuiAction::GotoBottom, + // In Detail mode: j/k scroll the JSON pane; arrows still navigate entities + KeyCode::Char('j') => { + if app.view_mode == ViewMode::Detail { + TuiAction::ScrollDetailDown + } else { + TuiAction::NextEntity + } + } + KeyCode::Char('k') => { + if app.view_mode == ViewMode::Detail { + TuiAction::ScrollDetailUp + } else { + TuiAction::PrevEntity + } + } + KeyCode::Down => TuiAction::NextEntity, + KeyCode::Up => TuiAction::PrevEntity, + KeyCode::Char('G') => { + if app.view_mode == ViewMode::Detail { + TuiAction::ScrollDetailBottom + } else { + TuiAction::GotoBottom + } + } KeyCode::Char('g') => { if app.pending_g { - // gg = go to top - TuiAction::GotoTop + // gg = go to top (of list or detail pane) + if app.view_mode == ViewMode::Detail { + TuiAction::ScrollDetailTop + } else { + TuiAction::GotoTop + } } else { app.pending_g = true; app.pending_count = None; @@ -250,10 +275,18 @@ async fn run_loop( } } KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { - TuiAction::HalfPageDown + if app.view_mode == ViewMode::Detail { + TuiAction::ScrollDetailHalfDown + } else { + TuiAction::HalfPageDown + } } KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { - TuiAction::HalfPageUp + if app.view_mode == ViewMode::Detail { + TuiAction::ScrollDetailHalfUp + } else { + TuiAction::HalfPageUp + } } KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { TuiAction::ScrollDetailDown From 53ca3113b0dc66a18ba460db9e0dbc333e515b87 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:55:15 +0000 Subject: [PATCH 93/97] fix: stable history position during live streaming in TUI Previously history_position was relative (0=latest, N=Nth from latest), so when new frames arrived for the selected entity, the viewed content would jump because the same relative index now pointed to a different history entry. Now stores an absolute VecDeque index (history_anchor) when browsing history. The anchor stays fixed as new entries are appended. When the ring buffer evicts old entries (pop_front), the anchor is decremented to continue pointing at the same entry. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/commands/stream/store.rs | 11 +++ cli/src/commands/stream/tui/app.rs | 108 ++++++++++++++++++++++++++--- 2 files changed, 110 insertions(+), 9 deletions(-) diff --git a/cli/src/commands/stream/store.rs b/cli/src/commands/stream/store.rs index 219eddcb..5350003b 100644 --- a/cli/src/commands/stream/store.rs +++ b/cli/src/commands/stream/store.rs @@ -122,6 +122,17 @@ impl EntityStore { record.history.get(actual_idx) } + /// Get entity state at an absolute VecDeque index. + pub fn at_absolute(&self, key: &str, abs_idx: usize) -> Option<&HistoryEntry> { + let record = self.entities.get(key)?; + record.history.get(abs_idx) + } + + /// Get the history length for a key. + pub fn history_len(&self, key: &str) -> usize { + self.entities.get(key).map(|r| r.history.len()).unwrap_or(0) + } + /// Get the diff between two consecutive history entries. /// Returns (added/changed fields, removed fields). pub fn diff_at(&self, key: &str, index: usize) -> Option { diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index d1fe7cdc..cce7b9ed 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -57,6 +57,9 @@ pub struct App { entity_key_set: HashSet, pub selected_index: usize, pub history_position: usize, + /// Absolute VecDeque index when browsing history (position > 0). + /// Stays stable as new frames arrive. None when viewing latest. + history_anchor: Option, pub show_diff: bool, pub show_raw: bool, pub paused: bool, @@ -88,6 +91,7 @@ impl App { entity_key_set: HashSet::new(), selected_index: 0, history_position: 0, + history_anchor: None, show_diff: false, show_raw: false, paused: false, @@ -114,6 +118,29 @@ impl App { self.filtered_cache = None; } + /// Compensate history_anchor when the selected entity's history grows. + /// If a pop_front happened (len didn't grow despite a push), decrement anchor. + fn compensate_history_anchor(&mut self, updated_key: &str, len_before: usize) { + if let Some(anchor) = self.history_anchor { + if let Some(selected) = self.selected_key() { + if selected == updated_key { + let len_after = self.store.history_len(updated_key); + // pop_front happened if length didn't increase + if len_after == len_before { + if anchor == 0 { + // The entry we were viewing was evicted + self.set_status("History entry evicted"); + // Stay at oldest available + } else { + self.history_anchor = Some(anchor - 1); + } + } + // No pop: anchor stays valid (new entry appended to back) + } + } + } + } + pub fn apply_frame(&mut self, frame: Frame) { // Invalidation is cheap (sets to None). The cache is only rebuilt once per // render tick in ensure_filtered_cache(), not per-frame, since we drain all @@ -139,8 +166,10 @@ impl App { Operation::Upsert | Operation::Create => { let key = frame.key.clone(); let seq = frame.seq.clone(); + let len_before = self.store.history_len(&key); self.store .upsert(&key, frame.data, &frame.op, seq); + self.compensate_history_anchor(&key, len_before); if self.entity_key_set.insert(key.clone()) { self.entity_keys.push(key); } @@ -149,8 +178,10 @@ impl App { Operation::Patch => { let key = frame.key.clone(); let seq = frame.seq.clone(); + let len_before = self.store.history_len(&key); self.store .patch(&key, &frame.data, &frame.append, seq); + self.compensate_history_anchor(&key, len_before); if self.entity_key_set.insert(key.clone()) { self.entity_keys.push(key); } @@ -172,6 +203,7 @@ impl App { self.selected_index = self.entity_keys.len() - 1; } self.history_position = 0; + self.history_anchor = None; self.scroll_offset = 0; } Operation::Subscribed => { @@ -231,6 +263,7 @@ impl App { if count > 0 { self.selected_index = (self.selected_index + n).min(count - 1); self.history_position = 0; + self.history_anchor = None; self.scroll_offset = 0; } } @@ -238,6 +271,7 @@ impl App { let n = self.take_count(); self.selected_index = self.selected_index.saturating_sub(n); self.history_position = 0; + self.history_anchor = None; self.scroll_offset = 0; } TuiAction::FocusDetail => { @@ -253,31 +287,54 @@ impl App { } } TuiAction::HistoryBack => { - self.history_position += 1; - self.scroll_offset = 0; - // Clamp to max history for selected entity if let Some(key) = self.selected_key() { - if let Some(record) = self.store.get(&key) { - if self.history_position >= record.history.len() { - self.history_position = record.history.len().saturating_sub(1); + let hist_len = self.store.history_len(&key); + if hist_len == 0 { /* no-op */ } + else if let Some(anchor) = self.history_anchor { + // Already browsing — move anchor backward (toward older) + if anchor > 0 { + self.history_anchor = Some(anchor - 1); + self.history_position += 1; } + } else if hist_len >= 2 { + // Start browsing — anchor to second-to-last entry + self.history_anchor = Some(hist_len - 2); + self.history_position = 1; } } + self.scroll_offset = 0; } TuiAction::HistoryForward => { - self.history_position = self.history_position.saturating_sub(1); + if let Some(key) = self.selected_key() { + let hist_len = self.store.history_len(&key); + if let Some(anchor) = self.history_anchor { + if anchor + 1 >= hist_len { + // Reached latest — clear anchor + self.history_anchor = None; + self.history_position = 0; + self.history_anchor = None; + } else { + self.history_anchor = Some(anchor + 1); + self.history_position = self.history_position.saturating_sub(1); + } + } + } self.scroll_offset = 0; } TuiAction::HistoryOldest => { if let Some(key) = self.selected_key() { - if let Some(record) = self.store.get(&key) { - self.history_position = record.history.len().saturating_sub(1); + let hist_len = self.store.history_len(&key); + if hist_len > 0 { + self.history_anchor = Some(0); + self.history_position = hist_len.saturating_sub(1); } } self.scroll_offset = 0; } TuiAction::HistoryNewest => { self.history_position = 0; + self.history_anchor = None; + self.history_anchor = None; self.scroll_offset = 0; } TuiAction::ToggleDiff => { @@ -341,6 +398,7 @@ impl App { self.pending_count = None; self.selected_index = 0; self.history_position = 0; + self.history_anchor = None; self.scroll_offset = 0; } TuiAction::GotoBottom => { @@ -350,6 +408,7 @@ impl App { self.selected_index = count - 1; } self.history_position = 0; + self.history_anchor = None; self.scroll_offset = 0; } TuiAction::HalfPageDown => { @@ -360,6 +419,7 @@ impl App { self.selected_index = (self.selected_index + half * n).min(count - 1); } self.history_position = 0; + self.history_anchor = None; self.scroll_offset = 0; } TuiAction::HalfPageUp => { @@ -367,6 +427,7 @@ impl App { let half = self.visible_rows / 2; self.selected_index = self.selected_index.saturating_sub(half * n); self.history_position = 0; + self.history_anchor = None; self.scroll_offset = 0; } TuiAction::NextMatch => { @@ -380,6 +441,7 @@ impl App { self.selected_index = (self.selected_index + n) % count; } self.history_position = 0; + self.history_anchor = None; self.scroll_offset = 0; } } @@ -395,6 +457,7 @@ impl App { self.selected_index = count - 1; } self.history_position = 0; + self.history_anchor = None; self.scroll_offset = 0; self.list_state.select(Some(self.selected_index)); } @@ -425,10 +488,37 @@ impl App { } if self.show_diff { + // Use anchor-based index if available for stable diff view + if let Some(anchor) = self.history_anchor { + let entry = self.store.at_absolute(&key, anchor)?; + // Compute diff manually against previous entry + if anchor > 0 { + if let Some(prev) = self.store.at_absolute(&key, anchor - 1) { + let diff = serde_json::json!({ + "op": entry.op, + "state": entry.state, + "patch": entry.patch, + "previous_state": prev.state, + }); + return Some(serde_json::to_string_pretty(&diff).unwrap_or_default()); + } + } + return Some(serde_json::to_string_pretty(&serde_json::json!({ + "op": entry.op, + "state": entry.state, + "patch": entry.patch, + })).unwrap_or_default()); + } let diff = self.store.diff_at(&key, self.history_position)?; return Some(serde_json::to_string_pretty(&diff).unwrap_or_default()); } + // Use anchor for stable history browsing during streaming + if let Some(anchor) = self.history_anchor { + let entry = self.store.at_absolute(&key, anchor)?; + return Some(serde_json::to_string_pretty(&entry.state).unwrap_or_default()); + } + if self.history_position > 0 { let entry = self.store.at(&key, self.history_position)?; return Some(serde_json::to_string_pretty(&entry.state).unwrap_or_default()); From c3acf3a15b0e01fb544c2419d4db2d448cbcaea0 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:58:27 +0000 Subject: [PATCH 94/97] feat: compact inline array rendering in TUI detail pane Small arrays like [1, 2, 3] now render on a single line when they fit within the terminal width, instead of one element per line. Larger arrays that exceed the width still expand to multi-line. Uses a custom JSON formatter (compact_pretty) that tries the inline form for each array and falls back to expanded when it doesn't fit. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/commands/stream/tui/app.rs | 77 ++++++++++++++++++++++++++++-- cli/src/commands/stream/tui/mod.rs | 1 + cli/src/commands/stream/tui/ui.rs | 9 +++- 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index cce7b9ed..753cb3cb 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -2,12 +2,79 @@ use hyperstack_sdk::{parse_snapshot_entities, Frame, Operation}; use ratatui::widgets::ListState; use serde_json::Value; use std::collections::{HashSet, VecDeque}; +use std::fmt::Write as FmtWrite; use crate::commands::stream::snapshot::SnapshotRecorder; use crate::commands::stream::store::EntityStore; const MAX_STATUS_AGE_MS: u128 = 3000; +/// Pretty-print JSON with compact inline arrays when they fit within max_width. +pub fn compact_pretty(value: &Value, max_width: usize) -> String { + let mut out = String::new(); + write_value(&mut out, value, 0, max_width); + out +} + +fn write_value(out: &mut String, value: &Value, indent: usize, max_width: usize) { + match value { + Value::Object(map) => { + if map.is_empty() { + out.push_str("{}"); + return; + } + out.push_str("{\n"); + let inner = indent + 2; + for (i, (k, v)) in map.iter().enumerate() { + write_indent(out, inner); + let _ = write!(out, "\"{}\": ", k); + write_value(out, v, inner, max_width); + if i + 1 < map.len() { + out.push(','); + } + out.push('\n'); + } + write_indent(out, indent); + out.push('}'); + } + Value::Array(arr) => { + if arr.is_empty() { + out.push_str("[]"); + return; + } + // Try compact form: [elem1, elem2, ...] + let compact = serde_json::to_string(value).unwrap_or_default(); + if indent + compact.len() <= max_width { + out.push_str(&compact); + return; + } + // Fall back to expanded form + out.push_str("[\n"); + let inner = indent + 2; + for (i, v) in arr.iter().enumerate() { + write_indent(out, inner); + write_value(out, v, inner, max_width); + if i + 1 < arr.len() { + out.push(','); + } + out.push('\n'); + } + write_indent(out, indent); + out.push(']'); + } + _ => { + let s = serde_json::to_string(value).unwrap_or_default(); + out.push_str(&s); + } + } +} + +fn write_indent(out: &mut String, n: usize) { + for _ in 0..n { + out.push(' '); + } +} + pub enum TuiAction { Quit, NextEntity, @@ -71,6 +138,7 @@ pub struct App { pub update_count: u64, pub scroll_offset: u16, pub visible_rows: usize, + pub terminal_width: u16, pub pending_count: Option, pub pending_g: bool, pub list_state: ListState, @@ -103,6 +171,7 @@ impl App { update_count: 0, scroll_offset: 0, visible_rows: 30, + terminal_width: 120, pending_count: None, pending_g: false, list_state: ListState::default().with_selected(Some(0)), @@ -513,19 +582,21 @@ impl App { return Some(serde_json::to_string_pretty(&diff).unwrap_or_default()); } + let w = self.terminal_width as usize; + // Use anchor for stable history browsing during streaming if let Some(anchor) = self.history_anchor { let entry = self.store.at_absolute(&key, anchor)?; - return Some(serde_json::to_string_pretty(&entry.state).unwrap_or_default()); + return Some(compact_pretty(&entry.state, w)); } if self.history_position > 0 { let entry = self.store.at(&key, self.history_position)?; - return Some(serde_json::to_string_pretty(&entry.state).unwrap_or_default()); + return Some(compact_pretty(&entry.state, w)); } let record = self.store.get(&key)?; - Some(serde_json::to_string_pretty(&record.current).unwrap_or_default()) + Some(compact_pretty(&record.current, w)) } pub fn selected_history_len(&self) -> usize { diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index 9b0a5888..8f56bd8d 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -175,6 +175,7 @@ async fn run_loop( let term_size = terminal.size()?; // 3 fixed rows (header + timeline + status) + 2 border rows = 5 app.visible_rows = term_size.height.saturating_sub(5) as usize; + app.terminal_width = term_size.width; terminal.draw(|f| ui::draw(f, app))?; diff --git a/cli/src/commands/stream/tui/ui.rs b/cli/src/commands/stream/tui/ui.rs index aa96602b..0014ddac 100644 --- a/cli/src/commands/stream/tui/ui.rs +++ b/cli/src/commands/stream/tui/ui.rs @@ -270,8 +270,10 @@ fn colorize_json_line(line: &str) -> Line<'_> { return Line::from(Span::styled(line, Style::default().fg(Color::Green))); } - // Braces - if trimmed == "{" || trimmed == "}" || trimmed == "{}" || trimmed == "}," { + // Braces and brackets + if trimmed == "{" || trimmed == "}" || trimmed == "{}" || trimmed == "}," + || trimmed == "[" || trimmed == "]" || trimmed == "[]" || trimmed == "]," + { return Line::from(Span::styled(line, Style::default().fg(Color::DarkGray))); } @@ -288,6 +290,9 @@ fn colorize_value(rest: &str) -> Span<'_> { Span::styled(rest, Style::default().fg(Color::DarkGray)) } else if trimmed.parse::().is_ok() { Span::styled(rest, Style::default().fg(Color::Magenta)) + } else if trimmed.starts_with('[') || trimmed.starts_with("[]") { + // Inline compact array — render in default color (contains mixed types) + Span::styled(rest, Style::default().fg(Color::White)) } else { Span::raw(rest) } From 1bc31cc4d5eab2927a3b41e04a8696655b3fefc2 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:02:23 +0000 Subject: [PATCH 95/97] feat: dynamic entity sorting in TUI with S/O key bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Press S to cycle sort modes (insertion order → _seq field → insertion). Press O to toggle ascending/descending direction. Sort applies to the filtered cache after filtering, never mutates the raw entity_keys list. Numbers sort numerically, strings lexicographically, null/missing values sort last. Sort indicator shown in status bar: [_seq↓] or [_seq↑]. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/commands/stream/tui/app.rs | 111 ++++++++++++++++++++++++++++- cli/src/commands/stream/tui/mod.rs | 2 + cli/src/commands/stream/tui/ui.rs | 21 +++++- 3 files changed, 131 insertions(+), 3 deletions(-) diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index 753cb3cb..11cf56e0 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -101,6 +101,9 @@ pub enum TuiAction { ScrollDetailBottom, ScrollDetailHalfDown, ScrollDetailHalfUp, + // Sorting + CycleSortMode, + ToggleSortDirection, // Vim motions GotoTop, GotoBottom, @@ -115,6 +118,18 @@ pub enum ViewMode { Detail, } +#[derive(Clone, PartialEq)] +pub enum SortMode { + Insertion, + Field(String), +} + +#[derive(Clone, Copy, PartialEq)] +pub enum SortDirection { + Ascending, + Descending, +} + #[allow(dead_code)] pub struct App { pub view: String, @@ -139,6 +154,8 @@ pub struct App { pub scroll_offset: u16, pub visible_rows: usize, pub terminal_width: u16, + pub sort_mode: SortMode, + pub sort_direction: SortDirection, pub pending_count: Option, pub pending_g: bool, pub list_state: ListState, @@ -172,6 +189,8 @@ impl App { scroll_offset: 0, visible_rows: 30, terminal_width: 120, + sort_mode: SortMode::Insertion, + sort_direction: SortDirection::Descending, pending_count: None, pending_g: false, list_state: ListState::default().with_selected(Some(0)), @@ -414,6 +433,36 @@ impl App { self.show_raw = !self.show_raw; self.set_status(if self.show_raw { "Raw frames ON" } else { "Raw frames OFF" }); } + TuiAction::CycleSortMode => { + self.sort_mode = match &self.sort_mode { + SortMode::Insertion => SortMode::Field("_seq".to_string()), + SortMode::Field(_) => SortMode::Insertion, + }; + self.invalidate_filter_cache(); + let label = match &self.sort_mode { + SortMode::Insertion => "Sort: insertion order".to_string(), + SortMode::Field(f) => format!("Sort: {} {}", f, match self.sort_direction { + SortDirection::Ascending => "asc", + SortDirection::Descending => "desc", + }), + }; + self.set_status(&label); + } + TuiAction::ToggleSortDirection => { + self.sort_direction = match self.sort_direction { + SortDirection::Ascending => SortDirection::Descending, + SortDirection::Descending => SortDirection::Ascending, + }; + self.invalidate_filter_cache(); + let label = match &self.sort_mode { + SortMode::Insertion => "Sort direction toggled (no effect in insertion order)".to_string(), + SortMode::Field(f) => format!("Sort: {} {}", f, match self.sort_direction { + SortDirection::Ascending => "asc", + SortDirection::Descending => "desc", + }), + }; + self.set_status(&label); + } TuiAction::TogglePause => { self.paused = !self.paused; self.set_status(if self.paused { "PAUSED" } else { "Resumed" }); @@ -638,7 +687,7 @@ impl App { if self.filtered_cache.is_some() { return; } - let result = if self.filter_text.is_empty() { + let mut result = if self.filter_text.is_empty() { self.entity_keys.clone() } else { let lower = self.filter_text.to_lowercase(); @@ -656,10 +705,70 @@ impl App { .cloned() .collect() }; + // Apply sort if not insertion order + if let SortMode::Field(ref path) = self.sort_mode { + let path = path.clone(); + let dir = self.sort_direction; + let store = &self.store; + result.sort_by(|a, b| { + let va = store.get(a).and_then(|r| resolve_dot_path(&r.current, &path)); + let vb = store.get(b).and_then(|r| resolve_dot_path(&r.current, &path)); + let cmp = compare_json_values(va, vb); + match dir { + SortDirection::Ascending => cmp, + SortDirection::Descending => cmp.reverse(), + } + }); + } + self.filtered_cache = Some(result); } } +/// Resolve a dot-path like "_seq" or "info.name" into a JSON value. +fn resolve_dot_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> { + let mut current = value; + for segment in path.split('.') { + current = current.get(segment)?; + } + if current.is_null() { None } else { Some(current) } +} + +/// Compare two optional JSON values. Numbers compare numerically, strings +/// lexicographically, null/missing sorts last. +fn compare_json_values(a: Option<&Value>, b: Option<&Value>) -> std::cmp::Ordering { + match (a, b) { + (None, None) => std::cmp::Ordering::Equal, + (None, Some(_)) => std::cmp::Ordering::Greater, // missing sorts last + (Some(_), None) => std::cmp::Ordering::Less, + (Some(va), Some(vb)) => { + // Try numeric comparison first + if let (Some(na), Some(nb)) = (as_f64(va), as_f64(vb)) { + return na.partial_cmp(&nb).unwrap_or(std::cmp::Ordering::Equal); + } + // Fall back to string comparison + let sa = value_to_sort_string(va); + let sb = value_to_sort_string(vb); + sa.cmp(&sb) + } + } +} + +fn as_f64(v: &Value) -> Option { + match v { + Value::Number(n) => n.as_f64(), + Value::String(s) => s.parse::().ok(), + _ => None, + } +} + +fn value_to_sort_string(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + _ => serde_json::to_string(v).unwrap_or_default(), + } +} + /// Recursively search all values in a JSON tree for a substring match. fn value_contains_str(value: &Value, needle: &str) -> bool { match value { diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index 8f56bd8d..f5e574e0 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -319,6 +319,8 @@ async fn run_loop( KeyCode::Char('p') => TuiAction::TogglePause, KeyCode::Char('/') => TuiAction::StartFilter, KeyCode::Char('s') => TuiAction::SaveSnapshot, + KeyCode::Char('S') => TuiAction::CycleSortMode, + KeyCode::Char('O') => TuiAction::ToggleSortDirection, _ => { app.pending_count = None; app.pending_g = false; diff --git a/cli/src/commands/stream/tui/ui.rs b/cli/src/commands/stream/tui/ui.rs index 0014ddac..275882d9 100644 --- a/cli/src/commands/stream/tui/ui.rs +++ b/cli/src/commands/stream/tui/ui.rs @@ -6,7 +6,7 @@ use ratatui::{ Frame, }; -use super::app::{App, ViewMode}; +use super::app::{App, SortDirection, SortMode, ViewMode}; pub fn draw(f: &mut Frame, app: &mut App) { app.ensure_filtered_cache(); @@ -215,7 +215,24 @@ fn draw_status_bar(f: &mut Frame, app: &App, area: Rect) { Span::styled("s", Style::default().fg(Color::Yellow)), Span::styled("ave ", Style::default().fg(Color::DarkGray)), Span::styled("h/l", Style::default().fg(Color::Yellow)), - Span::styled(" history", Style::default().fg(Color::DarkGray)), + Span::styled(" history ", Style::default().fg(Color::DarkGray)), + Span::styled("S", Style::default().fg(Color::Yellow)), + Span::styled("ort ", Style::default().fg(Color::DarkGray)), + Span::styled("O", Style::default().fg(Color::Yellow)), + Span::styled("rder", Style::default().fg(Color::DarkGray)), + match &app.sort_mode { + SortMode::Insertion => Span::raw(""), + SortMode::Field(f) => Span::styled( + format!(" [{}{}]", + f, + match app.sort_direction { + SortDirection::Ascending => "↑", + SortDirection::Descending => "↓", + } + ), + Style::default().fg(Color::Cyan), + ), + }, ]); f.render_widget(Paragraph::new(status), area); From 8e560f8a3858b680a44510ad63f92bd6a0b9605f Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:26:41 +0000 Subject: [PATCH 96/97] fix: clamp detail scroll to content height, show line indicator - G now scrolls to the last line of JSON, not past it - All scroll actions clamp to max_scroll_offset (total lines - visible) - Detail mode border is yellow to indicate focus state - Scroll position shown in title: [line N/total] Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/commands/stream/tui/app.rs | 23 +++++++++++++++++++---- cli/src/commands/stream/tui/ui.rs | 21 +++++++++++++++++++-- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/cli/src/commands/stream/tui/app.rs b/cli/src/commands/stream/tui/app.rs index 11cf56e0..376ca684 100644 --- a/cli/src/commands/stream/tui/app.rs +++ b/cli/src/commands/stream/tui/app.rs @@ -322,7 +322,8 @@ impl App { TuiAction::Quit => {} TuiAction::ScrollDetailDown => { let n = self.take_count(); - self.scroll_offset = self.scroll_offset.saturating_add(n as u16); + self.scroll_offset = self.scroll_offset.saturating_add(n as u16) + .min(self.max_scroll_offset()); } TuiAction::ScrollDetailUp => { let n = self.take_count(); @@ -334,12 +335,12 @@ impl App { } TuiAction::ScrollDetailBottom => { self.pending_count = None; - // Set to a large value; rendering will clamp naturally - self.scroll_offset = u16::MAX; + self.scroll_offset = self.max_scroll_offset(); } TuiAction::ScrollDetailHalfDown => { let half = (self.visible_rows / 2).max(1); - self.scroll_offset = self.scroll_offset.saturating_add(half as u16); + self.scroll_offset = self.scroll_offset.saturating_add(half as u16) + .min(self.max_scroll_offset()); } TuiAction::ScrollDetailHalfUp => { let half = (self.visible_rows / 2).max(1); @@ -580,6 +581,20 @@ impl App { self.list_state.select(Some(self.selected_index)); } + /// Maximum scroll offset for the detail pane (total lines - visible height). + fn max_scroll_offset(&self) -> u16 { + let total_lines = self.selected_entity_data() + .map(|s| s.lines().count()) + .unwrap_or(0); + // visible_rows approximates the detail pane height (minus borders) + let visible = self.visible_rows.saturating_sub(2); + if total_lines > visible { + (total_lines - visible) as u16 + } else { + 0 + } + } + pub fn selected_key(&self) -> Option { let keys = self.filtered_keys(); keys.get(self.selected_index).map(|s| s.to_string()) diff --git a/cli/src/commands/stream/tui/ui.rs b/cli/src/commands/stream/tui/ui.rs index 275882d9..6a4d1a8d 100644 --- a/cli/src/commands/stream/tui/ui.rs +++ b/cli/src/commands/stream/tui/ui.rs @@ -143,12 +143,29 @@ fn draw_entity_detail(f: &mut Frame, app: &App, area: Rect) { .map(|line| colorize_json_line(line)) .collect(); + // Count total lines for scroll indicator + let total_lines = content.lines().count(); + let visible_height = area.height.saturating_sub(2) as usize; // minus borders + let current_line = app.scroll_offset as usize + 1; + let scroll_info = if total_lines > visible_height { + format!(" [line {}/{}]", current_line, total_lines) + } else { + String::new() + }; + + let block_title = format!("{}{}", title, scroll_info); + let border_color = if app.view_mode == ViewMode::Detail { + Color::Yellow // highlight border in detail mode + } else { + Color::Cyan + }; + let detail = Paragraph::new(lines) .block( Block::default() - .title(title) + .title(block_title) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)), + .border_style(Style::default().fg(border_color)), ) .wrap(Wrap { trim: false }); From 817f389d7704f37a09890b543712fe136e7292a7 Mon Sep 17 00:00:00 2001 From: VimMotions <220220743+vimmotions@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:34:14 +0000 Subject: [PATCH 97/97] fix: swap sort/save key bindings for ergonomics - s = cycle sort mode (was S) - o = toggle sort direction (was O) - S = save snapshot (was s) Lowercase for frequent actions, uppercase for the less common save. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/commands/stream/tui/mod.rs | 6 +++--- cli/src/commands/stream/tui/ui.rs | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cli/src/commands/stream/tui/mod.rs b/cli/src/commands/stream/tui/mod.rs index f5e574e0..b4e4e4bc 100644 --- a/cli/src/commands/stream/tui/mod.rs +++ b/cli/src/commands/stream/tui/mod.rs @@ -318,9 +318,9 @@ async fn run_loop( KeyCode::Char('r') => TuiAction::ToggleRaw, KeyCode::Char('p') => TuiAction::TogglePause, KeyCode::Char('/') => TuiAction::StartFilter, - KeyCode::Char('s') => TuiAction::SaveSnapshot, - KeyCode::Char('S') => TuiAction::CycleSortMode, - KeyCode::Char('O') => TuiAction::ToggleSortDirection, + KeyCode::Char('s') => TuiAction::CycleSortMode, + KeyCode::Char('o') => TuiAction::ToggleSortDirection, + KeyCode::Char('S') => TuiAction::SaveSnapshot, _ => { app.pending_count = None; app.pending_g = false; diff --git a/cli/src/commands/stream/tui/ui.rs b/cli/src/commands/stream/tui/ui.rs index 6a4d1a8d..2fdf0417 100644 --- a/cli/src/commands/stream/tui/ui.rs +++ b/cli/src/commands/stream/tui/ui.rs @@ -230,13 +230,13 @@ fn draw_status_bar(f: &mut Frame, app: &App, area: Rect) { Span::styled("/", Style::default().fg(Color::Yellow)), Span::styled("filter ", Style::default().fg(Color::DarkGray)), Span::styled("s", Style::default().fg(Color::Yellow)), + Span::styled("ort ", Style::default().fg(Color::DarkGray)), + Span::styled("o", Style::default().fg(Color::Yellow)), + Span::styled("rder ", Style::default().fg(Color::DarkGray)), + Span::styled("S", Style::default().fg(Color::Yellow)), Span::styled("ave ", Style::default().fg(Color::DarkGray)), Span::styled("h/l", Style::default().fg(Color::Yellow)), Span::styled(" history ", Style::default().fg(Color::DarkGray)), - Span::styled("S", Style::default().fg(Color::Yellow)), - Span::styled("ort ", Style::default().fg(Color::DarkGray)), - Span::styled("O", Style::default().fg(Color::Yellow)), - Span::styled("rder", Style::default().fg(Color::DarkGray)), match &app.sort_mode { SortMode::Insertion => Span::raw(""), SortMode::Field(f) => Span::styled(