diff --git a/CHANGELOG.md b/CHANGELOG.md
index cdebd25..5bcb542 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,27 @@ reads that marker.
## [Unreleased]
+### Added
+
+- `Recording::duration()` — how long a recording spans, `Duration::ZERO`
+ when it holds no frame (#307). The span runs from `Terminal::record` to
+ the end of the last *complete* frame, not the program's lifetime; it is
+ the `frames().last()` arithmetic every caller was writing by hand.
+
+- The asciicast header carries `timestamp`, `duration` and `title` (#309).
+ A `.cast` attached to a bug report or a CI artifact now has a date and a
+ name in `asciinema` and in the web player, where before it had neither.
+ The title is the command the terminal spawned; the timestamp is taken at
+ export, and is omitted rather than written as `0` if the clock is set
+ before the epoch.
+
+- `Screen::to_svg` gives the image an accessible name: `role="img"` on the
+ root and a `
` first child reading `termlens screen, 80x24`, with
+ the application's own `Screen::title` after it when one was set (#316).
+ These files exist to be attached to a pull request, where an image with
+ no accessible name is announced as nothing at all. The title is escaped
+ with the same helper the text rows use.
+
## [0.11.0] - 2026-09-11
### Changed
diff --git a/crates/termlens/src/screen/render.rs b/crates/termlens/src/screen/render.rs
index 7206c5f..066f1bd 100644
--- a/crates/termlens/src/screen/render.rs
+++ b/crates/termlens/src/screen/render.rs
@@ -173,6 +173,12 @@ impl Screen {
/// monospace fallback font stack and no font embedding — enough for a
/// bug report or a README, not a typeset transcript. Concealed text is
/// drawn as blanks, as a terminal shows it; dim is opacity.
+ ///
+ /// The root carries `role="img"` and a `` naming the picture —
+ /// `termlens screen, 80x24`, and the application's own
+ /// [`title`](Self::title) after it when one was set. These files are made
+ /// to be attached to a pull request or a bug report, where an image with
+ /// no accessible name is announced as nothing at all.
#[must_use]
pub fn to_svg(&self) -> String {
const CELL_W: u32 = 9;
@@ -184,8 +190,21 @@ impl Screen {
out,
""
+ Menlo, Consolas, 'DejaVu Sans Mono', monospace\" font-size=\"14\" \
+ role=\"img\">"
);
+ // The accessible name, and the first child because that is where a
+ // reader looks for it (#316). The size alone is a poor name but an
+ // honest one; the application's own title is the better one when it
+ // set one, and it is arbitrary text, so it is escaped like any cell.
+ out.push_str("");
+ let mut name = format!("termlens screen, {}x{}", self.cols(), self.rows());
+ if !self.title().is_empty() {
+ name.push_str(": ");
+ name.push_str(self.title());
+ }
+ escape_xml(&name, &mut out);
+ out.push_str(" \n");
let _ = writeln!(
out,
""
diff --git a/crates/termlens/src/terminal.rs b/crates/termlens/src/terminal.rs
index ee18fb1..37d4da8 100644
--- a/crates/termlens/src/terminal.rs
+++ b/crates/termlens/src/terminal.rs
@@ -9,13 +9,17 @@
use std::collections::VecDeque;
use std::ffi::{OsStr, OsString};
use std::fmt;
+// The asciicast header is assembled key by key because two of its keys
+// are optional (#309); `write!` into a String needs this in scope, and the
+// anonymous import keeps `Write` meaning `io::Write` everywhere else.
+use std::fmt::Write as _;
use std::io::{self, Read, Write};
use std::panic::{self, AssertUnwindSafe};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{mpsc, Arc, Mutex, PoisonError};
use std::thread;
-use std::time::{Duration, Instant};
+use std::time::{Duration, Instant, SystemTime};
use portable_pty::{native_pty_system, CommandBuilder, PtyPair, PtySize};
@@ -713,6 +717,9 @@ impl RecordingState {
pub struct Recorder {
state: Arc>,
shared: Arc>,
+ /// The command being recorded, for the asciicast `title` (#309). Carried
+ /// from the terminal because `stop` has no other way back to it.
+ title: String,
}
impl fmt::Debug for Recorder {
@@ -755,6 +762,7 @@ impl Recorder {
Ok(Recording {
frames: state.frames.iter().cloned().collect(),
dropped: state.dropped,
+ title: self.title.clone(),
})
}
}
@@ -765,6 +773,7 @@ impl Recorder {
pub struct Recording {
frames: Vec<(Duration, Screen)>,
dropped: u64,
+ title: String,
}
impl Recording {
@@ -794,6 +803,19 @@ impl Recording {
self.dropped
}
+ /// How long the recording spans: the timestamp of its last frame, and
+ /// [`Duration::ZERO`] when no frame was recorded.
+ ///
+ /// The span is measured between [`Terminal::record`] and the end of the
+ /// last **complete** frame — not the program's lifetime. A program that
+ /// draws its final repaint and then sits at a prompt for a second has a
+ /// recording a second shorter than its run, and one whose last repaint
+ /// was never bracketed by DEC 2026 markers has no frame to end at.
+ #[must_use]
+ pub fn duration(&self) -> Duration {
+ self.frames.last().map_or(Duration::ZERO, |(at, _)| *at)
+ }
+
/// The recording as an [asciicast v2] document: a header line, then one
/// event per frame at its timestamp, each a full repaint — clear, home,
/// then the frame through [`Screen::to_ansi`] — which is what the format
@@ -807,10 +829,23 @@ impl Recording {
.frames
.first()
.map_or((80, 24), |(_, frame)| frame.size());
- let mut out = format!(
- "{{\"version\": 2, \"width\": {cols}, \"height\": {rows}, \
- \"env\": {{\"TERM\": \"xterm-256color\"}}}}\n"
- );
+ // The header is one line of JSON, assembled key by key because two of
+ // the four optional keys the v2 spec defines may be absent (#309).
+ // Without them a player shows a recording with no date and no name,
+ // which is the first question asked of a file attached to a bug
+ // report. Order follows the spec's own listing.
+ let mut out = format!("{{\"version\": 2, \"width\": {cols}, \"height\": {rows}");
+ // A clock set before the epoch has no honest unix timestamp, so the
+ // key is omitted rather than written as 0 — a reader that meets no
+ // timestamp shows none, where one that meets 1970 shows a wrong date.
+ if let Ok(since_epoch) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) {
+ let _ = write!(out, ", \"timestamp\": {}", since_epoch.as_secs());
+ }
+ let _ = write!(out, ", \"duration\": {:.6}", self.duration().as_secs_f64());
+ if !self.title.is_empty() {
+ let _ = write!(out, ", \"title\": {}", json_string(&self.title));
+ }
+ out.push_str(", \"env\": {\"TERM\": \"xterm-256color\"}}\n");
for (at, frame) in &self.frames {
let mut data = String::from("\x1b[H\x1b[2J");
// `to_ansi` ends every row with a newline, the bottom one
@@ -2579,6 +2614,7 @@ impl Terminal {
Recorder {
state,
shared: Arc::clone(&self.shared),
+ title: self.command_desc.clone(),
}
}
diff --git a/crates/termlens/tests/export.rs b/crates/termlens/tests/export.rs
index 12cdf87..661bdff 100644
--- a/crates/termlens/tests/export.rs
+++ b/crates/termlens/tests/export.rs
@@ -19,8 +19,17 @@ fn emit(steps: &[&str]) -> termlens::Result {
/// A small styled screen every rendering below is made from: a bold cyan
/// title, a reverse-video highlight, a dim note, a concealed field, a wide
/// character and an RGB background.
+///
+/// It sets its window title too, and to something holding `<` and `&`: the
+/// SVG names itself with it (#316), so the rendering has to escape it. The
+/// title is set explicitly rather than left unset because ConPTY sets one of
+/// its own when the application does not, and the snapshots below are shared
+/// with the Windows leg — an application that sets its own title reads back
+/// exactly as set there (`observe.rs` asserts that on every platform).
fn styled() -> termlens::Result {
emit(&[
+ "--raw",
+ r"\e]0;myapp <2> & co\a",
"--raw",
r"\e[1;36mmyapp\e[0m \e[7m> Alpha\e[0m 汉字\n",
"--raw",
@@ -116,6 +125,19 @@ fn the_three_renderings_are_pure_functions_of_the_screen() -> termlens::Result<(
svg.starts_with(""), "{svg}");
+ let title_at = svg.find("").expect("a ");
+ assert!(
+ svg[..title_at].ends_with(">\n"),
+ "the title is the first child, not buried: {svg}"
+ );
+ assert!(
+ svg.contains("termlens screen, 30x4: myapp <2> & co "),
+ "the title is escaped, not pasted: {svg}"
+ );
assert!(
svg.contains("汉字"),
"a wide character is one glyph: {svg}"
diff --git a/crates/termlens/tests/record.rs b/crates/termlens/tests/record.rs
index 0bd8475..f48497d 100644
--- a/crates/termlens/tests/record.rs
+++ b/crates/termlens/tests/record.rs
@@ -198,3 +198,111 @@ fn the_asciicast_replays_every_row_of_every_frame() -> termlens::Result<()> {
}
Ok(())
}
+
+/// `duration()` is the span a recording covers, and the arithmetic every
+/// caller was writing by hand over `frames().last()` (#307).
+#[test]
+#[cfg_attr(
+ windows,
+ ignore = "ConPTY closes a DEC 2026 bracket before the content it wrapped, so a recorded frame never holds what was drawn (#149)"
+)]
+fn a_recording_spans_up_to_its_last_frame() -> termlens::Result<()> {
+ let mut t = emit(
+ Terminal::builder(),
+ &["READY", "--wait", "--raw", BURST, " DONE", "--wait"],
+ )?;
+ t.wait_until(|s| s.contains("READY"))?;
+ let rec = t.record();
+ t.send(Key::Enter)?;
+ t.wait_until(|s| s.contains("DONE"))?;
+ let frames = rec.stop()?;
+
+ let last = frames.frames().last().expect("three frames").0;
+ assert_eq!(frames.duration(), last, "the span ends at the last frame");
+ assert!(
+ frames
+ .frames()
+ .iter()
+ .all(|(at, _)| *at <= frames.duration()),
+ "no frame lands after the span ends: {:?}",
+ frames
+ .frames()
+ .iter()
+ .map(|(at, _)| *at)
+ .collect::>()
+ );
+
+ // A recorder that starts after the last repaint has nothing to span.
+ // The refusal in `stop` is about the *application* never bracketing an
+ // update, not about this recorder having missed them all, so this is a
+ // recording with no frames rather than an error.
+ let empty = t.record().stop()?;
+ assert!(empty.is_empty());
+ assert_eq!(empty.duration(), Duration::ZERO, "no frames, no span");
+
+ t.send(Key::Enter)?;
+ assert!(t.wait_exit()?.success());
+ Ok(())
+}
+
+/// The asciicast header carries the optional keys a player shows: when the
+/// recording was taken and what was recorded (#309). Without them a file
+/// attached to a bug report answers neither question.
+#[test]
+#[cfg_attr(
+ windows,
+ ignore = "ConPTY closes a DEC 2026 bracket before the content it wrapped, so a recorded frame never holds what was drawn (#149)"
+)]
+fn the_asciicast_header_dates_and_names_the_recording() -> termlens::Result<()> {
+ let mut t = emit(
+ Terminal::builder(),
+ &["READY", "--wait", "--raw", BURST, " DONE", "--wait"],
+ )?;
+ t.wait_until(|s| s.contains("READY"))?;
+ let before = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("a clock after 1970")
+ .as_secs();
+ let rec = t.record();
+ t.send(Key::Enter)?;
+ t.wait_until(|s| s.contains("DONE"))?;
+ let frames = rec.stop()?;
+
+ let cast = frames.to_asciicast();
+ let header = cast.lines().next().expect("a header line");
+ let parsed: serde_json::Value =
+ serde_json::from_str(header).expect("the header is still one line of valid JSON");
+
+ let timestamp = parsed["timestamp"].as_u64().expect("unix seconds");
+ let after = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("a clock after 1970")
+ .as_secs();
+ assert!(
+ (before..=after).contains(×tamp),
+ "exported now, not at some other time: {timestamp} outside {before}..={after}"
+ );
+
+ // The title is the command, which here carries the fixture's own
+ // backslash escapes — so it also proves the key is JSON-escaped rather
+ // than pasted in.
+ let title = parsed["title"].as_str().expect("a title");
+ assert!(title.contains("emit"), "the command recorded: {title}");
+ assert!(title.contains(BURST), "its arguments too: {title}");
+
+ let duration = parsed["duration"].as_f64().expect("a duration");
+ assert!(
+ (duration - frames.duration().as_secs_f64()).abs() < 1e-6,
+ "the header's duration is the recording's: {duration}"
+ );
+
+ // The keys the header always had are unchanged.
+ assert_eq!(parsed["version"], 2);
+ assert_eq!(parsed["width"], 40);
+ assert_eq!(parsed["height"], 6);
+ assert_eq!(parsed["env"]["TERM"], "xterm-256color");
+
+ t.send(Key::Enter)?;
+ assert!(t.wait_exit()?.success());
+ Ok(())
+}
diff --git a/crates/termlens/tests/snapshots/export__json__styled_json.snap b/crates/termlens/tests/snapshots/export__json__styled_json.snap
index f0dcaec..be86eda 100644
--- a/crates/termlens/tests/snapshots/export__json__styled_json.snap
+++ b/crates/termlens/tests/snapshots/export__json__styled_json.snap
@@ -2084,7 +2084,7 @@ expression: s
]
],
"state": {
- "title": "",
+ "title": "myapp <2> & co",
"alternate_screen": false,
"bracketed_paste": false,
"application_cursor": false,
diff --git a/crates/termlens/tests/snapshots/export__styled_svg.snap b/crates/termlens/tests/snapshots/export__styled_svg.snap
index 3f9d890..65d9b2e 100644
--- a/crates/termlens/tests/snapshots/export__styled_svg.snap
+++ b/crates/termlens/tests/snapshots/export__styled_svg.snap
@@ -2,7 +2,8 @@
source: crates/termlens/tests/export.rs
expression: svg
---
-
+
+termlens screen, 30x4: myapp <2> & co
myapp