diff --git a/Cargo.lock b/Cargo.lock index 94c70cb..6b5379b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,6 +314,17 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "comfy-table" +version = "7.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" +dependencies = [ + "crossterm", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -341,6 +352,29 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix", + "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 = "crypto-common" version = "0.1.7" @@ -420,6 +454,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "either" version = "1.17.0" @@ -968,6 +1011,12 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1823,6 +1872,7 @@ dependencies = [ "base64", "bytes", "clap", + "comfy-table", "dirs", "flate2", "futures-util", @@ -2243,6 +2293,18 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -2416,6 +2478,28 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index e29c2b6..6d386fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -116,6 +116,11 @@ tapes-client = { version = "0.4", features = [ clap = { version = "4", features = ["derive", "env", "string"] } anstream = "1" anstyle = "1" +# The table `sessions list` renders by default. Terminal-width handling and +# unicode-width measurement come free, which a hand-rolled column aligner would +# have to reimplement (and get subtly wrong on CJK/emoji cells). Plain text: no +# colour is applied — tapesctl has no style layer (see `ports/search.rs`). +comfy-table = "7.1" # --- async runtime --- tokio = { version = "1", features = ["full"] } diff --git a/README.md b/README.md index 8e6ba79..7aaaa9f 100644 --- a/README.md +++ b/README.md @@ -163,12 +163,13 @@ tapesctl spans list tapesctl spans get ``` -Each prints the server's JSON verbatim, so it composes with `jq`. `sessions -list` pages with `--limit`/`--cursor` and narrows with `--sort`, -`--direction`, `--since`, `--until`, and `--auth-subject`; a cursor is only -valid with the `--sort` and `--direction` it was minted under. `sessions -traces` and `spans list` take `--payload preview` to truncate payload strings -server-side. +`sessions list` renders its listing as a table by default; `--json` restores the +raw document so it still composes with `jq`. The other commands print the +server's JSON verbatim. `sessions list` pages with `--limit`/`--cursor` and +narrows with `--sort`, `--direction`, `--since`, `--until`, and +`--auth-subject`; a cursor is only valid with the `--sort` and `--direction` it +was minted under. `sessions traces` and `spans list` take `--payload preview` +to truncate payload strings server-side. ```bash tapesctl export -o bundle.jsonl # --detail spans (default) or traces diff --git a/crates/tapesctl/Cargo.toml b/crates/tapesctl/Cargo.toml index 86a72a1..c9d5801 100644 --- a/crates/tapesctl/Cargo.toml +++ b/crates/tapesctl/Cargo.toml @@ -25,6 +25,7 @@ axum = { workspace = true } base64 = { workspace = true } bytes = { workspace = true } clap = { workspace = true } +comfy-table = { workspace = true } dirs = { workspace = true } flate2 = { workspace = true } futures-util = { workspace = true } diff --git a/crates/tapesctl/src/api/mod.rs b/crates/tapesctl/src/api/mod.rs index c15c444..10c5df3 100644 --- a/crates/tapesctl/src/api/mod.rs +++ b/crates/tapesctl/src/api/mod.rs @@ -21,11 +21,14 @@ //! //! # Output //! -//! Every command prints the server's JSON, pretty-printed, and nothing else. See -//! [`client`] for why these particular responses are not decoded through the -//! shared models on the way through: in short, a model can only carry the -//! fields the build it shipped in knew about, and these commands exist to show -//! what the server said. +//! `sessions list` renders its listing as a table by default; `--json` restores +//! the raw document. Every other command prints the server's JSON, +//! pretty-printed, and nothing else. See [`client`] for why these particular +//! responses are not decoded through the shared models on the way through: in +//! short, a model can only carry the fields the build it shipped in knew about, +//! and these commands exist to show what the server said. The table view keeps +//! that spirit by reading its columns off the undecoded document — see +//! [`table`]. //! //! # Requests //! @@ -36,6 +39,7 @@ pub mod client; pub mod contract; +pub mod table; use serde_json::Value; use snafu::{OptionExt, ResultExt}; @@ -107,7 +111,12 @@ pub async fn sessions(command: SessionsCommand) -> Result<()> { values.push(("direction", direction)); } let value: Value = client.call(ops::LIST_SESSIONS, values).await?; - print_json(&value) + if args.json { + print_json(&value) + } else { + print!("{}", table::render_sessions(&value)); + Ok(()) + } } SessionsCommand::Get(args) => { let client = resolve_client(&args.api)?; @@ -208,6 +217,7 @@ mod tests { harness_session_id: None, harness_id: None, auth_subject: None, + json: false, } } diff --git a/crates/tapesctl/src/api/table.rs b/crates/tapesctl/src/api/table.rs new file mode 100644 index 0000000..930019e --- /dev/null +++ b/crates/tapesctl/src/api/table.rs @@ -0,0 +1,405 @@ +//! The table view for `tapesctl sessions list`. +//! +//! `sessions list` renders its listing as a table by default; `--json` restores +//! the pretty-printed document. This module turns the undecoded +//! [`serde_json::Value`] the read client returns into that table, reading only +//! the fields the table has columns for and leaving the rest of the document +//! alone. That is the same projection spirit as `spans list`: a field the +//! server has not sent renders as a dash rather than failing the command, and a +//! field the server grows simply has no column yet. + +use comfy_table::presets::UTF8_FULL; +use comfy_table::{Cell, Table}; +use serde_json::Value; +use time::OffsetDateTime; +use time::UtcOffset; +use time::format_description::well_known::Rfc3339; + +/// Longest title the table shows before eliding it with an ellipsis. +const TITLE_WIDTH: usize = 44; + +/// Longest harness id before it is elided. +const HARNESS_WIDTH: usize = 16; + +/// Longest model id before it is elided. +const MODEL_WIDTH: usize = 24; + +/// What a missing field renders as, so an absent value is visibly absent. +const DASH: &str = "—"; + +/// Render the `GET /v1/sessions` document as a table, with the page cursor +/// (when the server says there is another page) as a trailing line. +/// +/// The document is the undecoded response, so the columns read only what they +/// need and an absent field is a dash, never an error. An empty listing renders +/// one line instead of a header with no rows. +#[must_use] +pub fn render_sessions(value: &Value) -> String { + let Some(items) = value.get("items").and_then(Value::as_array) else { + return "No sessions.\n".to_owned(); + }; + + if items.is_empty() { + return "No sessions.\n".to_owned(); + } + + let mut table = Table::new(); + table.load_preset(UTF8_FULL).set_header(vec![ + Cell::new("ID"), + Cell::new("TITLE"), + Cell::new("STATUS"), + Cell::new("HARNESS"), + Cell::new("MODEL"), + Cell::new("TURNS"), + Cell::new("COST"), + Cell::new("STARTED"), + ]); + + for item in items { + table.add_row(vec![ + Cell::new(sanitize(string_at(item, &["id"]))), + Cell::new(title(item)), + Cell::new(status(item)), + Cell::new(elided(item, &["harness_id"], HARNESS_WIDTH)), + Cell::new(elided(item, &["rollup", "model"], MODEL_WIDTH)), + Cell::new(turns(item)), + Cell::new(cost(item)), + Cell::new(started_at(item)), + ]); + } + + let mut rendered = table.to_string(); + // comfy-table's Display does not end with a newline, so a listing with no + // cursor would otherwise hand the shell a prompt glued to the bottom border. + if !rendered.ends_with('\n') { + rendered.push('\n'); + } + if let Some(cursor) = value + .get("next_cursor") + .and_then(Value::as_str) + .filter(|cursor| !cursor.is_empty()) + { + rendered.push_str(&format!("\nnext cursor: {}\n", sanitize(cursor))); + } + rendered +} + +/// The session label the console would render, falling back the way the server +/// does: display title, then the captured name. +fn title(item: &Value) -> String { + let title = sanitize(string_at(item, &["display_title"])); + if !title.is_empty() { + return elide(&title, TITLE_WIDTH); + } + let name = sanitize(string_at(item, &["name"])); + if !name.is_empty() { + return elide(&name, TITLE_WIDTH); + } + DASH.to_owned() +} + +/// The session's status: the deriver's word when it has one, otherwise the +/// liveness signal, otherwise a dash. +fn status(item: &Value) -> String { + let status = sanitize(string_at(item, &["rollup", "status"])); + if !status.is_empty() { + return status; + } + if bool_at(item, &["live"]).unwrap_or(false) { + return "live".to_owned(); + } + DASH.to_owned() +} + +/// The folded turn count, or a dash when the rollup did not arrive. +fn turns(item: &Value) -> String { + match number_at(item, &["rollup", "turn_count"]) { + Some(count) => count.to_string(), + None => DASH.to_owned(), + } +} + +/// The folded spend in US dollars, to four decimals, or a dash. +fn cost(item: &Value) -> String { + match float_at(item, &["rollup", "usage", "cost_usd"]) { + Some(cost) => format!("${cost:.4}"), + None => DASH.to_owned(), + } +} + +/// The session start time at second precision, or a dash. +fn started_at(item: &Value) -> String { + let raw = sanitize(string_at(item, &["started_at"])); + if raw.is_empty() { + return DASH.to_owned(); + } + format_seconds(&raw) +} + +/// Read a string field nested under `path`, empty when any hop is missing. +fn string_at<'a>(value: &'a Value, path: &[&str]) -> &'a str { + let mut node = value; + for key in path { + node = match node.get(*key) { + Some(next) => next, + None => return "", + }; + } + node.as_str().unwrap_or("") +} + +/// Read an integer field nested under `path`. +fn number_at(value: &Value, path: &[&str]) -> Option { + let mut node = value; + for key in path { + node = node.get(*key)?; + } + node.as_i64() +} + +/// Read a floating-point field nested under `path`, accepting an integer as +/// well — the server may render a whole-dollar cost as `0` rather than `0.0`. +fn float_at(value: &Value, path: &[&str]) -> Option { + let mut node = value; + for key in path { + node = node.get(*key)?; + } + node.as_f64().or_else(|| node.as_i64().map(|n| n as f64)) +} + +/// Read a boolean field nested under `path`. +fn bool_at(value: &Value, path: &[&str]) -> Option { + let mut node = value; + for key in path { + node = node.get(*key)?; + } + node.as_bool() +} + +/// Truncate to `width`, marking the cut with an ellipsis. +/// +/// Counted in characters rather than bytes, so a multi-byte rune is never split +/// in half (the same guard `ports/search.rs` carries for the same reason). +fn elide(value: &str, width: usize) -> String { + if value.chars().count() <= width { + return value.to_owned(); + } + let kept: String = value.chars().take(width.saturating_sub(3)).collect(); + format!("{kept}...") +} + +/// Read a string field, neutralize its control characters, and elide to `width`. +fn elided(item: &Value, path: &[&str], width: usize) -> String { + elide(&sanitize(string_at(item, path)), width) +} + +/// Replace control and bidirectional formatting characters with spaces so a +/// server-returned value cannot inject terminal control sequences (ESC, +/// carriage return, backspace, C1 controls), reorder displayed text, or break a +/// cell's layout with an embedded newline. +/// +/// These fields are the server's to set; sanitizing at the render boundary is +/// what keeps a hostile or buggy response from steering the user's terminal. +fn sanitize(value: &str) -> String { + value + .chars() + .map(|c| { + if c.is_control() + || matches!( + c, + '\u{061c}' + | '\u{200e}' + | '\u{200f}' + | '\u{202a}'..='\u{202e}' + | '\u{2066}'..='\u{2069}' + ) + { + ' ' + } else { + c + } + }) + .collect() +} + +/// Render an RFC 3339 timestamp at second precision, in UTC. +/// +/// A value that will not parse is shown as it arrived: it is the server's field, +/// and showing it beats showing nothing. +fn format_seconds(raw: &str) -> String { + let Ok(parsed) = OffsetDateTime::parse(raw, &Rfc3339) else { + return raw.to_owned(); + }; + let utc = parsed.to_offset(UtcOffset::UTC); + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", + utc.year(), + u8::from(utc.month()), + utc.day(), + utc.hour(), + utc.minute(), + utc.second(), + ) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn an_empty_listing_renders_one_line_instead_of_a_header() { + assert_eq!(render_sessions(&json!({"items": []})), "No sessions.\n",); + } + + #[test] + fn a_document_without_items_renders_one_line_too() { + assert_eq!( + render_sessions(&json!({"next_cursor": "c"})), + "No sessions.\n", + ); + } + + #[test] + fn a_row_renders_every_column() { + let rendered = render_sessions(&json!({ + "items": [{ + "id": "01JDQ8F3K2M4N6P8R0T2V4X6Z8", + "display_title": "Add a table view", + "harness_id": "claude", + "started_at": "2026-07-31T05:34:56-07:00", + "live": false, + "rollup": { + "status": "ended", + "model": "claude-opus", + "turn_count": 12, + "usage": {"cost_usd": 0.0421} + } + }], + "next_cursor": "" + })); + + assert!( + rendered.contains("01JDQ8F3K2M4N6P8R0T2V4X6Z8"), + "got: {rendered}" + ); + assert!(rendered.contains("Add a table view"), "got: {rendered}"); + assert!(rendered.contains("ended"), "got: {rendered}"); + assert!(rendered.contains("claude"), "got: {rendered}"); + assert!(rendered.contains("claude-opus"), "got: {rendered}"); + assert!(rendered.contains("12"), "got: {rendered}"); + assert!(rendered.contains("$0.0421"), "got: {rendered}"); + assert!(rendered.contains("2026-07-31T12:34:56Z"), "got: {rendered}"); + } + + #[test] + fn a_missing_field_is_a_dash_not_a_failure() { + let rendered = render_sessions(&json!({"items": [{"id": "s-1"}]})); + assert!(rendered.contains("—"), "got: {rendered}"); + assert!(rendered.contains("s-1"), "got: {rendered}"); + } + + #[test] + fn a_whole_dollar_cost_is_still_a_dollar() { + let rendered = render_sessions(&json!({ + "items": [{"id": "s-1", "rollup": {"usage": {"cost_usd": 0}}}] + })); + assert!(rendered.contains("$0.0000"), "got: {rendered}"); + } + + #[test] + fn a_live_session_without_a_status_word_reads_live() { + let rendered = render_sessions(&json!({ + "items": [{"id": "s-1", "live": true}] + })); + assert!(rendered.contains("live"), "got: {rendered}"); + } + + #[test] + fn a_next_cursor_is_printed_for_paging() { + let rendered = render_sessions(&json!({ + "items": [{"id": "s-1"}], + "next_cursor": "abc123" + })); + assert!(rendered.contains("next cursor: abc123"), "got: {rendered}"); + } + + #[test] + fn sanitize_replaces_control_and_bidi_characters_with_spaces() { + assert_eq!(sanitize("plain"), "plain"); + assert_eq!(sanitize("a\tb"), "a b"); + assert_eq!(sanitize("a\r\nb"), "a b"); + assert_eq!(sanitize("a\x1b[31mb"), "a [31mb"); + assert_eq!(sanitize("a\u{202e}b\u{2066}c"), "a b c"); + } + + #[test] + fn server_control_characters_are_sanitized_before_render() { + let rendered = render_sessions(&json!({ + "items": [{ + "id": "s-1", + "display_title": "evil\x1b[2Jtitle", + "harness_id": "cla\x1bude", + "rollup": { + "status": "ok\x1b[31mred", + "model": "gpt\r\n5", + } + }], + "next_cursor": "abc\x1b[3J" + })); + + // The only control character the table may emit is the newline that + // frames it; everything else must be neutralized. + assert!( + rendered.chars().all(|c| !c.is_control() || c == '\n'), + "control characters leaked: {rendered:?}" + ); + assert!(rendered.contains("evil [2Jtitle"), "got: {rendered}"); + assert!(rendered.contains("ok [31mred"), "got: {rendered}"); + assert!(rendered.contains("next cursor: abc [3J"), "got: {rendered}"); + } + + #[test] + fn a_listing_ends_with_a_newline_either_way() { + // Without this the shell prompt lands on the bottom border's line. + let no_cursor = render_sessions(&json!({"items": [{"id": "s-1"}]})); + assert!(no_cursor.ends_with('\n'), "got: {no_cursor:?}"); + assert!(!no_cursor.ends_with("\n\n"), "got: {no_cursor:?}"); + + let with_cursor = render_sessions(&json!({ + "items": [{"id": "s-1"}], + "next_cursor": "abc" + })); + assert!( + with_cursor.ends_with("next cursor: abc\n"), + "got: {with_cursor:?}" + ); + } + + #[test] + fn a_long_value_is_elided_without_splitting_a_rune() { + let long = "é".repeat(200); + let elided = elide(&long, MODEL_WIDTH); + assert_eq!(elided.chars().count(), MODEL_WIDTH); + assert!(elided.ends_with("...")); + assert!(elided.starts_with('é')); + } + + #[test] + fn an_unparseable_timestamp_is_shown_rather_than_swallowed() { + assert_eq!(format_seconds("not a time"), "not a time"); + } + + #[test] + fn timestamps_print_at_second_precision_in_utc() { + assert_eq!( + format_seconds("2026-07-31T12:34:56.123456789Z"), + "2026-07-31T12:34:56Z", + ); + assert_eq!( + format_seconds("2026-07-31T05:34:56-07:00"), + "2026-07-31T12:34:56Z", + ); + } +} diff --git a/crates/tapesctl/src/cli.rs b/crates/tapesctl/src/cli.rs index cba3dd5..343af57 100644 --- a/crates/tapesctl/src/cli.rs +++ b/crates/tapesctl/src/cli.rs @@ -532,6 +532,14 @@ pub struct SessionsListArgs { /// Only sessions stamped with this acting subject. #[arg(long)] pub auth_subject: Option, + + /// Print the raw JSON response instead of the table. + /// + /// `sessions list` renders a table by default; this restores the + /// pretty-printed JSON the read commands printed before, so the output still + /// composes with `jq`. + #[arg(long)] + pub json: bool, } /// A session id and where to find its server. diff --git a/docs/commands.md b/docs/commands.md index 423865f..0663571 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -241,13 +241,14 @@ as a success. ## sessions -Read commands. Each prints the server's JSON pretty-printed and nothing else. -Responses are never re-modelled on the way through, so fields the server grows -reach you without a client upgrade. +Read commands. `sessions list` renders its listing as a table by default, with +`--json` restoring the raw document; every other command prints the server's +JSON pretty-printed. Responses are never re-modelled on the way through, so +fields the server grows reach you without a client upgrade. | leaf | route | flags | |---|---|---| -| `list` | `GET /v1/sessions` | `--limit`, `--cursor`, `--sort`, `--direction`, `--since`, `--until`, `--harness-id`, `--harness-session-id`, `--auth-subject` | +| `list` | `GET /v1/sessions` | `--limit`, `--cursor`, `--sort`, `--direction`, `--since`, `--until`, `--harness-id`, `--harness-session-id`, `--auth-subject`, `--json` | | `get ` | `GET /v1/sessions/{id}` | — | | `traces ` | `GET /v1/sessions/{id}/traces` | `--payload` | | `raw-turns ` | `GET /v1/sessions/{id}/raw_turns` | — | @@ -272,6 +273,7 @@ come as a pair — a lone half fails at parse with the missing half named. | `--harness-id ` | the harness the session ran under (e.g. `claude`) — the other half of the pair | | `--harness-session-id ` | exact match on the harness session id — the id `start` prints; pairs with `--harness-id`; see [Session ids](./capture.md#session-ids) | | `--auth-subject ` | exact match | +| `--json` | print the raw pretty-printed JSON instead of the table, so the output still composes with `jq` | `--payload` takes `full` (the default) or `preview`, case-insensitively. An unknown value fails **before any request is made**: diff --git a/docs/introduction.md b/docs/introduction.md index c554e69..8ede244 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -130,8 +130,9 @@ Read the session with the `id` from the listing: tapesctl sessions get 01JDQ8F3K2M4N6P8R0T2V4X6Z8 --api-url http://localhost:8081 ``` -Every read command prints the server's JSON pretty-printed and nothing else, so -it composes with `jq`. +`sessions list` renders its listing as a table by default; `--json` restores +the raw document so it still composes with `jq`. The other read commands print +the server's JSON pretty-printed and nothing else. ## Where to go next