Prune commentary and stale Rust port notes

This commit is contained in:
iris committed 2026-09-10 00:44:13 -04:00
1 parent 5428cd75c9
commit 25370731d0
193 files changed
+693 -16219

No files matched your search

-78
View File
@@ -1,25 +1,3 @@
//! What a tool printed, with its terminal styling applied and everything
//! else taken out. Ported from `app/.../Ansi.kt`, module for module: the
//! Kotlin version builds a Compose `AnnotatedString`, which does not exist
//! here, so a [`StyledText`] of plain text plus non-overlapping
//! `(Range, Style)` spans stands in for it -- a future UI layer maps
//! [`Style`] onto whatever it draws with.
//!
//! Bash output arrives exactly as the program wrote it, escape sequences
//! included, and drawn verbatim those are line noise in the middle of the
//! thing being read. Stripping them all would be the other half-answer --
//! colour is often the whole of what a diff or a test run is saying.
//!
//! So the sequences that decide how text *looks* become spans, and every
//! other one is dropped rather than shown: the rest move a cursor around a
//! grid this is not, and "go to column 40" has no meaning in a scrolling
//! document.
//!
//! A carriage return is honoured the way a terminal honours it: what was
//! written since the last line break is thrown away and the line starts
//! again. That is what makes a progress bar show its final state rather
//! than every state it passed through.
use std::ops::Range;
/// An RGB colour, the same shape wherever this crate names one -- no alpha,
@@ -38,18 +16,10 @@ impl Rgb {
}
}
/// The sixteen colours a terminal program names, and the two it assumes.
///
/// Its own palette rather than the syntax one: a program that prints in red
/// has chosen red, where a highlighter's colours are this app's reading of
/// somebody else's code.
#[derive(Debug, Clone)]
pub struct AnsiPalette {
/// Indexes 0-7, then 8-15 bright, in the terminal's own order.
pub colours: [Rgb; 16],
/// What uncoloured text is, needed only where a style has to state a colour.
pub foreground: Rgb,
/// What the text sits on, needed for reverse video.
pub background: Rgb,
}
@@ -67,8 +37,6 @@ pub struct Style {
pub strikethrough: bool,
}
/// Plain text plus the non-overlapping, ordered spans that style parts of it
/// -- this crate's stand-in for Compose's `AnnotatedString`.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct StyledText {
pub text: String,
@@ -87,11 +55,7 @@ impl StyledText {
const ESC: char = '\u{1B}';
const BELL: char = '\u{7}';
/// [text] with its terminal styling applied and everything else taken out;
/// see the module doc.
pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
// The common case by a long way -- nothing to do, and nothing allocated
// to find that out.
if !text.contains(ESC) && !text.contains('\r') {
return StyledText::plain(text.to_string());
}
@@ -118,19 +82,12 @@ pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
}
});
} else if c == '\r' && chars.get(at + 1) != Some(&'\n') {
// A bare carriage return rewrites the line. One before a newline
// is the other half of a Windows line ending: it rewrites
// nothing, and it is dropped rather than kept, since that pair
// is one line break.
flush(&mut plain, sgr, &mut runs);
drop_line(&mut runs);
at += 1;
} else if c == '\r' {
at += 1;
} else if c >= ' ' || c == '\n' || c == '\t' {
// Everything printable, plus the two control characters that are
// layout rather than terminal commands. A stray bell or
// backspace goes for the same reason a cursor move does.
plain.push(c);
at += 1;
} else {
@@ -151,8 +108,6 @@ pub fn ansi_styled(text: &str, palette: &AnsiPalette) -> StyledText {
StyledText { text: out, spans }
}
/// Throws away everything written since the last line break, as a carriage
/// return does.
fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
while let Some((text, style)) = runs.pop() {
if let Some(break_at) = text.rfind('\n') {
@@ -162,7 +117,6 @@ fn drop_line(runs: &mut Vec<(String, Option<Style>)>) {
}
}
/// The bytes that end a CSI sequence.
fn is_csi_final(c: char) -> bool {
('@'..='~').contains(&c)
}
@@ -184,10 +138,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
end += 1;
}
if end >= chars.len() {
// Cut off mid-sequence, which is what a stream that has not
// finished arriving looks like: drop the fragment rather
// than printing it, and the whole sequence arrives with the
// next delta.
chars.len()
} else {
let params: String = chars[at + 2..end].iter().collect();
@@ -196,8 +146,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
}
}
']' | 'P' | 'X' | '^' | '_' => {
// Runs to a string terminator: `ESC \`, or the bell that xterm
// allows after an OSC.
let mut end = at + 2;
while end < chars.len() {
if chars[end] == BELL {
@@ -214,7 +162,6 @@ fn skip_escape(chars: &[char], at: usize, mut on_csi: impl FnMut(&str, char)) ->
}
}
/// Everything an SGR sequence can turn on, as the terminal tracks it.
#[derive(Debug, Clone, Copy, PartialEq)]
struct Sgr {
fg: Option<Rgb>,
@@ -227,7 +174,6 @@ struct Sgr {
reverse: bool,
}
/// How much of its colour dim text keeps: enough to read, little enough to recede.
const DIM_ALPHA: f32 = 0.65;
impl Sgr {
@@ -242,7 +188,6 @@ impl Sgr {
reverse: false,
};
/// `None` while nothing is set, so unstyled output costs no spans at all.
fn span(&self, palette: &AnsiPalette) -> Option<Style> {
if *self == Sgr::PLAIN {
return None;
@@ -275,15 +220,7 @@ impl Sgr {
})
}
/// This state with `params` applied -- one `ESC[...m`, which carries any
/// number of them.
///
/// A code this does not model is ignored rather than reset from: the
/// program meant something by it, and starting again would also drop
/// the codes beside it that are understood.
fn apply(&self, params: &str, palette: &AnsiPalette) -> Sgr {
// `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a
// zero too.
let codes: Vec<i64> = params
.split(';')
.map(|p| p.trim().parse::<i64>().unwrap_or(0))
@@ -377,12 +314,6 @@ impl Sgr {
}
}
/// The colour named by a `38`/`48` at `at`, and the index of that colour's
/// last parameter.
///
/// Two forms: `5;n` for the 256-colour table and `2;r;g;b` for a literal
/// one. The first sixteen of that table are the palette's own, so a program
/// asking for "colour 1" through either spelling gets the same red.
fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<Rgb>, usize) {
match codes.get(at + 1) {
Some(&5) => match codes.get(at + 2) {
@@ -409,11 +340,8 @@ fn extended_colour(codes: &[i64], at: usize, palette: &AnsiPalette) -> (Option<R
}
}
/// The six levels of each channel in the 256-colour cube, as xterm defines them.
const CUBE: [u8; 6] = [0, 95, 135, 175, 215, 255];
/// One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a
/// grey ramp.
fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
if n < 0 {
palette.foreground
@@ -434,8 +362,6 @@ fn indexed_colour(n: i64, palette: &AnsiPalette) -> Rgb {
mod tests {
use super::*;
/// A palette matching the Kotlin test's: `colours[i] = Rgb(i, 0, 0)`,
/// white foreground, black background.
fn palette() -> AnsiPalette {
let mut colours = [Rgb::new(0, 0, 0); 16];
for (i, c) in colours.iter_mut().enumerate() {
@@ -452,8 +378,6 @@ mod tests {
ansi_styled(text, &palette())
}
/// The style covering the first character of `word`, or `None` where
/// nothing styles it.
fn style_over(text: &str, word: &str) -> Option<Style> {
let out = styled(text);
let at = out
@@ -509,8 +433,6 @@ mod tests {
#[test]
fn everything_that_is_not_styling_is_dropped_rather_than_printed() {
// A cursor move, an erase, an OSC window title with its bell, and a
// bare two-character escape.
let text = format!("a{ESC}[2Jb{ESC}[Kc{ESC}]0;a title{BELL}d{ESC}=e");
assert_eq!(styled(&text).text, "abcde");
}
-63
View File
@@ -1,22 +1,9 @@
//! The REST half of the backend's surface (see `server/src/routes.rs`'s
//! module doc for the table); the SSE half is [`crate::client::event_stream`].
//! Ported from `app/.../Api.kt`, but **not at full parity yet** -- see
//! `CLIENT_CORE.md` for exactly which routes have a typed method here and
//! which do not.
//!
//! Network I/O sits behind the [`Transport`] trait so the rest of this
//! crate, and anything built on it, can be tested against a fake one with
//! no server involved. [`UreqTransport`] is the only real implementation.
use std::io::Read;
use event_model::SeqEvent;
use serde::Deserialize;
use serde_json::Value;
/// A request that did not produce what it asked for, carrying the server's
/// own wording where it sent some.
///
/// `status` is the HTTP status where there was a response at all, and
/// `None` where the server was never reached -- mirroring `ApiException` in
/// `Api.kt`.
@@ -33,8 +20,6 @@ impl std::fmt::Display for ApiError {
}
impl std::error::Error for ApiError {}
/// A request body to send, in whichever of the two shapes the surface
/// takes: `Api.kt`'s `jsonBody` and `streamBody`.
pub enum Body {
Json(Value),
Bytes {
@@ -51,10 +36,7 @@ pub struct RawResponse {
pub body: Vec<u8>,
}
/// The network boundary this crate's pure logic is kept out from behind.
/// `server/src/routes.rs`'s module doc is the surface this drives.
pub trait Transport: Send + Sync {
/// One request/response call -- everything but the long-lived SSE GETs.
fn request(
&self,
method: &str,
@@ -62,10 +44,6 @@ pub trait Transport: Send + Sync {
body: Option<Body>,
) -> Result<RawResponse, ApiError>;
/// Opens `path` and answers a reader over the response body, for a
/// caller that reads it as a stream rather than all at once (the SSE
/// connections in [`crate::client::event_stream`]). Fails the same way
/// [`Transport::request`] does for a non-2xx response.
fn stream(&self, path: &str) -> Result<Box<dyn Read + Send>, ApiError>;
}
@@ -104,10 +82,6 @@ fn default_true() -> bool {
true
}
/// A client-core equivalent of `requestFromServer` plus the typed calls
/// built on it. Holds no state of its own beyond the transport -- the
/// session id or setup id a call is about is a parameter, per this
/// project's "ask for the least you need".
pub struct ApiClient<T: Transport> {
transport: T,
}
@@ -282,19 +256,6 @@ impl<T: Transport> ApiClient<T> {
)
}
/// A page of transcript history, each line handed back paired with the
/// exact text it came from, and bounded below by `after` -- the shape
/// `crate::client::transcript_source::TranscriptSource` needs to store what it
/// fetched in the transcript cache without a second round trip to fetch
/// the raw text separately. Ported from `Api.kt`'s `fetchTranscript`.
///
/// Uses [`serde_json::value::RawValue`] rather than re-serializing a
/// parsed [`Value`], so the stored line is the exact bytes the server
/// sent (key order and float literal included) rather than this
/// crate's own idea of how to write them back out -- the cache and a
/// live SSE frame must agree byte-for-byte on the same event, which is
/// exactly what caught the `serde_json` float-rounding bug this
/// project's `AGENTS.md` records.
pub fn fetch_transcript_lines(
&self,
session_id: &str,
@@ -320,9 +281,6 @@ impl<T: Transport> ApiClient<T> {
}
}
/// The query string shared by [`ApiClient::fetch_transcript_page`] and
/// [`ApiClient::fetch_transcript_lines`], so the two agree on how each
/// parameter is written rather than keeping two copies to drift.
fn transcript_path(
session_id: &str,
before: Option<u64>,
@@ -343,12 +301,6 @@ fn transcript_path(
path
}
/// The blocking [`Transport`] backed by `ureq`, the same crate `server/`
/// already depends on for its own outbound HTTPS (`usage.rs`'s Anthropic
/// poll). Verifies the server's leaf against a single pinned CA, the way
/// `ServerConfig.kt`'s `applyPinnedTls` does, rather than the system trust
/// store -- the server's certificate is self-signed on purpose (see
/// `wg-app-link`).
pub struct UreqTransport {
agent: ureq::Agent,
base_url: String,
@@ -356,8 +308,6 @@ pub struct UreqTransport {
}
impl UreqTransport {
/// `ca_pem` is the CA certificate `wg-app-link`'s `enroll` minted,
/// exactly as read from `certs/ca.pem`.
pub fn new(
base_url: impl Into<String>,
token: impl Into<String>,
@@ -372,10 +322,6 @@ impl UreqTransport {
.build();
let agent: ureq::Agent = ureq::Agent::config_builder()
.tls_config(tls_config)
// Read the body ourselves on every status, the way
// `requestFromServer` does: the server's own error wording is
// in the body of a 4xx/5xx, and the default behaviour throws
// it away before this code can read it.
.http_status_as_error(false)
.timeout_connect(Some(std::time::Duration::from_secs(5)))
.build()
@@ -453,9 +399,6 @@ impl Transport for UreqTransport {
.get(&url)
.header("Authorization", &auth)
.header("Accept", "text/event-stream")
// No read timeout: between events there is nothing to read for
// as long as the thing being followed is idle, mirroring
// `EventStream.kt`'s `readTimeout = 0`.
.config()
.timeout_recv_response(None)
.build()
@@ -481,9 +424,6 @@ fn transport_error(base_url: &str, path: &str, e: ureq::Error) -> ApiError {
}
}
/// The 401 wording matches `Api.kt`'s, since that message is instructions
/// for the reader rather than a diagnostic -- see this project's UI rule
/// about shortening a failure in one place rather than at each display site.
fn response_error(status: u16, body: &[u8], path: &str) -> ApiError {
let detail = String::from_utf8_lossy(body).trim().to_string();
let message = if status == 401 {
@@ -507,8 +447,6 @@ mod tests {
use std::io::Cursor;
use std::sync::Mutex;
/// A transport with no network at all, for the pure-logic tests this
/// module can run without a server.
#[derive(Default)]
struct FakeTransport {
responses: Mutex<Vec<(String, String, RawResponse)>>,
@@ -569,7 +507,6 @@ mod tests {
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "s1");
assert_eq!(sessions[0].setup_name, "desktop");
// Defaults for fields the server omits.
assert!(sessions[0].notify);
assert_eq!(sessions[0].model, None);
}
-49
View File
@@ -1,30 +1,8 @@
//! What a Rust client needs to reach one enrolled server: host, port and
//! bearer token. Mirrors the shape `ServerConfig.kt`/`Api.kt`'s
//! `handleEnrollment` parses out of an `aiapp://enroll?host=H&port=P&token=T`
//! deep link -- the exact link `wg-app-link`'s `enroll` module mints and
//! `app/ui-sandbox.sh`'s banner prints, so any Rust client can enrol from
//! the same text a phone would scan as a QR, with no second format
//! invented for it (RUST.md's E4).
//!
//! [`EnrollmentStore`] persists one of these as JSON, owner-only, in a
//! directory the caller names -- `$XDG_CONFIG_HOME/ai-app-desktop` for the
//! desktop app, the app-private files directory on Android. **Which**
//! directory is the only part left to the platform: the format, the file
//! mode and the "nothing saved yet is not an error" answer are the same on
//! both, and were written twice before this.
//!
//! JSON rather than the project's usual RON: `wg-app-link`'s RON house
//! rules (`format`) are for configs a person hand-edits, and this file
//! never is one -- only the app itself writes or reads it.
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::io;
use std::path::{Path, PathBuf};
/// One enrolled server: reachable at `https://{host}:{port}`, authenticated
/// with `token` as a bearer header.
///
/// `ca_pem` is the trust anchor to pin, when the link carried one (the
/// `ca` parameter, `wg_app_link::enroll::ca_param`). It is optional
/// because an app built on the machine its server runs on pins the CA at
@@ -37,19 +15,11 @@ pub struct EnrolledServer {
pub host: String,
pub port: u16,
pub token: String,
/// `#[serde(default)]` so an enrollment saved before this field
/// existed still loads, as the enrolled server it always was.
#[serde(default)]
pub ca_pem: Option<String>,
}
impl EnrolledServer {
/// Parses `aiapp://enroll?host=H&port=P&token=T[&ca=B]` (query order
/// does not matter; unrecognised keys are ignored). `token` is
/// percent-decoded, since `ui-sandbox.sh` encodes it precisely because
/// a raw token can contain `+`, which turns into a space if left to a
/// naive splitter.
///
/// `ca` is base64url of the certificate's DER and is rebuilt into PEM
/// here, because that is what every consumer of it wants
/// (`UreqTransport::new`, and the file a person points `curl --cacert`
@@ -99,13 +69,11 @@ impl EnrolledServer {
})
}
/// Where a `crate::client::api::UreqTransport` reaches this server.
pub fn base_url(&self) -> String {
format!("https://{}:{}", self.host, self.port)
}
}
/// The `ca` parameter (base64url of DER, unpadded) as a PEM certificate.
fn pem_from_link_param(ca: &str) -> Result<String, String> {
let der = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(ca.as_bytes())
@@ -140,9 +108,6 @@ impl EnrollmentStore {
self.dir.join("enrollment.json")
}
/// Writes `server` under `dir`, creating it if needed, and sets the
/// file owner-only -- it carries a bearer token, the same reason
/// `server/`'s own token store is 0600.
pub fn save(&self, server: &EnrolledServer) -> io::Result<()> {
std::fs::create_dir_all(&self.dir)?;
let path = self.file();
@@ -157,10 +122,6 @@ impl EnrollmentStore {
Ok(())
}
/// `Ok(None)` when nothing has been enrolled yet, rather than an error
/// -- "not enrolled" is an ordinary first-run state, not a failure
/// (UI_RULES' "a deliberate choice is not a problem to report" applies
/// just as well to a file that simply hasn't been written yet).
pub fn load(&self) -> io::Result<Option<EnrolledServer>> {
let path = self.file();
match std::fs::read(&path) {
@@ -232,8 +193,6 @@ mod tests {
#[test]
fn a_percent_encoded_token_is_decoded() {
// ui-sandbox.sh's own reason for encoding: a raw '+' would
// otherwise arrive as a space.
let server =
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=a%2Bb%2Fc").unwrap();
assert_eq!(server.token, "a+b/c");
@@ -248,9 +207,6 @@ mod tests {
);
}
/// The CA travels as base64url of the DER and comes back out as the
/// PEM every consumer of it wants -- the same round trip
/// `wg_app_link::enroll::ca_param` mints.
#[test]
fn a_ca_in_the_link_comes_back_as_pem() {
let der = [0x30u8, 0x82, 0x01, 0xfb, 0x3e, 0x7f];
@@ -276,16 +232,12 @@ mod tests {
);
}
/// A link with no `ca` is an ordinary link, not a broken one: an app
/// that pins at build time mints and reads exactly these.
#[test]
fn no_ca_parameter_is_none_not_an_error() {
let server = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t").unwrap();
assert_eq!(server.ca_pem, None);
}
/// The half that cannot be noticed later: a `ca` that does not decode
/// must fail the link rather than enrolling with nothing pinned.
#[test]
fn a_ca_that_does_not_decode_fails_the_link() {
let err =
@@ -314,7 +266,6 @@ mod tests {
assert_eq!(EnrollmentStore::new(dir.path()).load().unwrap(), None);
}
/// An enrollment written before `ca_pem` existed still loads.
#[test]
fn an_enrollment_without_a_ca_still_loads() {
let dir = tempfile::tempdir().unwrap();
-26
View File
@@ -1,12 +1,3 @@
//! A span of milliseconds, written the way somebody reads it -- the port
//! of `Durations.kt`'s `formatMillis`/`formatMillisText`, with its tests.
//!
//! Only the tool-timeout half is here. `formatSpan` (the usage
//! countdown's rounding-up rule) belongs with whatever draws the usage
//! bar, and nothing in this crate needs it yet.
/// A span of milliseconds, written the way somebody reads it.
///
/// A tool's timeout arrives as `480000`, which nobody reads as eight
/// minutes. The rule has two halves, because a short span and a long one
/// are read for different things. Under a minute the question is "roughly
@@ -14,9 +5,6 @@
/// rest -- `2.5s`. At a minute or more the question is "how long exactly",
/// so every unit with something in it is written out -- `5d 12h 4m`. Empty
/// units are left out rather than written as zero.
///
/// Sub-second precision is dropped past a minute: nothing that takes days
/// is measured in milliseconds.
pub fn format_millis(ms: i64) -> String {
if ms < 0 {
return format!("-{}", format_millis(-ms));
@@ -47,8 +35,6 @@ pub fn format_millis(ms: i64) -> String {
.join(" ")
}
/// `text` as a span when it is a whole number of milliseconds, and
/// unchanged when it is not.
pub fn format_millis_text(text: &str) -> String {
match text.trim().parse::<i64>() {
Ok(ms) => format_millis(ms),
@@ -60,40 +46,28 @@ pub fn format_millis_text(text: &str) -> String {
mod tests {
use super::*;
/// The two ways a span of time is written here, and the rule each of
/// them follows -- ported from `DurationsTest.kt`, whose doc says why:
/// both are read off a screen to make a decision, so what matters is
/// that the shortest form that answers the question is what appears.
#[test]
fn under_a_minute_is_the_largest_unit_alone() {
assert_eq!(format_millis(30), "30ms");
assert_eq!(format_millis(999), "999ms");
assert_eq!(format_millis(1000), "1s");
assert_eq!(format_millis(2500), "2.5s");
// One decimal, rounded rather than cut: 2.46s is nearer two and a
// half than two and four.
assert_eq!(format_millis(2460), "2.5s");
assert_eq!(format_millis(59_900), "59.9s");
}
#[test]
fn a_minute_or_more_is_every_unit_that_has_something_in_it() {
// The figure this rule was written for: a tool timeout, which
// arrives as milliseconds and is unreadable as 480000.
assert_eq!(format_millis(480_000), "8m");
assert_eq!(format_millis(60_000), "1m");
assert_eq!(format_millis(90_000), "1m 30s");
assert_eq!(format_millis(475_440_000), "5d 12h 4m");
// Empty units are left out rather than written as zero: the labels
// say which is which, and "5d 0h 4m" is only longer.
assert_eq!(format_millis(432_240_000), "5d 4m");
}
#[test]
fn only_a_whole_number_of_milliseconds_is_rewritten() {
assert_eq!(format_millis_text(" 480000 "), "8m");
// A timeout a tool expressed some other way is its own words,
// passed through rather than guessed at.
assert_eq!(format_millis_text("2 minutes"), "2 minutes");
assert_eq!(format_millis_text(""), "");
}
-17
View File
@@ -1,8 +1,3 @@
//! The SSE half of the API: one long-lived GET per open session screen,
//! replaying the transcript after a cursor and then following it live.
//! Ported from `app/.../EventStream.kt`; the framing itself is
//! [`crate::client::sse`].
use std::io::{BufRead, BufReader};
use event_model::SeqEvent;
@@ -19,19 +14,8 @@ const RESET_EVENT: &str = "reset";
/// callbacks were for, as a single enum instead, since Rust has no
/// equivalent of handing three closures to one blocking call.
pub enum StreamItem {
/// The connection was accepted; the measured moment the stream is live
/// (see `EventStream.kt`'s doc on `onOpen` for why this, not the first
/// event, is what clears a previous failure on screen).
Open,
/// The cursor was too far behind to continue from: everything already
/// displayed is stale, and the events that follow are a fresh window.
/// Arrives before those events, so a caller that clears on it stays in
/// order.
Reset,
/// One event, as both the raw line the transcript cache stores and the
/// parsed [`SeqEvent`] the fold works from -- they have to be the same
/// line, so both travel together rather than being parsed twice from
/// two call sites.
Event { raw: String, event: SeqEvent },
}
@@ -59,7 +43,6 @@ pub fn follow_session_events(
let Some(frame) = reader.feed_line(&line) else {
continue;
};
// A named frame carries no payload and a data frame has no name.
if frame.name.as_deref() == Some(RESET_EVENT) {
if !on_item(StreamItem::Reset) {
return Ok(());
@@ -1,8 +1,3 @@
//! A language the highlighter can colour, and the data-driven [`Rules`] each
//! one scans by. Ported from `app/.../Languages.kt`; see that file's doc for
//! why nearly every language is a row of data read by one shared scanner,
//! with Markdown the one exception (`super::markdown`).
use std::collections::HashSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -32,8 +27,6 @@ pub enum Language {
}
impl Language {
/// Every value, for the same exhaustiveness check the Kotlin test runs
/// (`Language.entries`).
pub const ALL: [Language; 22] = [
Language::C,
Language::Coffeescript,
@@ -60,24 +53,14 @@ impl Language {
];
}
/// What [`super::scan`] needs to know about one language -- data, not code,
/// so that adding a language is a row here rather than a branch anywhere.
#[derive(Debug, Clone, Default)]
pub struct Rules {
/// Words drawn as keywords. Only plain words; the scanner cannot reach
/// anything else.
pub keywords: HashSet<&'static str>,
/// Tokens that open a comment running to the end of the line.
pub line_comments: Vec<&'static str>,
/// Whether `line_comments` count only at the start of a word. The shells
/// need it: `$#`, `${#x}` and `a#b` are not comments.
pub line_comments_at_word_start: bool,
pub block_comment: Option<BlockComment>,
/// The string forms. The longest opener that matches wins, so `"""` is
/// tried before `"`.
pub quotes: Vec<Quote>,
pub attributes: Attributes,
/// Rust and RON: an optional `b`, `r`, n hashes, `"`, closing at `"` and n hashes.
pub raw_strings: bool,
/// Rust: `'` opens a character literal only when a backslash or one
/// character and a `'` follow. Otherwise it is a lifetime or a label.
@@ -91,8 +74,6 @@ pub struct BlockComment {
pub nests: bool,
}
/// One string form. `escapes` is whether a backslash escapes the closer
/// (and itself).
#[derive(Debug, Clone, Copy)]
pub struct Quote {
pub open: &'static str,
@@ -100,18 +81,13 @@ pub struct Quote {
pub escapes: bool,
}
/// What opens a metadata span, of the shapes that exist across these languages.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Attributes {
#[default]
None,
/// `@` and a word: Kotlin and Java annotations, Python decorators.
AtWord,
/// `#[` or `#![` through the matching `]`: Rust and RON attributes.
HashBracket,
/// `#` at the start of a line, to the end of it: the C preprocessor.
HashLine,
/// `[` at the start of a line through the matching `]`: a TOML table header.
LineBracket,
}
@@ -151,10 +127,6 @@ fn words(list: &'static str) -> HashSet<&'static str> {
list.split_whitespace().collect()
}
/// The rules for one language. A `match` rather than a lazily-built map --
/// there is no once-per-process cost worth paying for in a language table
/// this small, and it sidesteps the Kotlin version's own workaround for
/// property initialization order.
pub fn rules_for(language: Language) -> Rules {
match language {
Language::C => Rules {
@@ -180,8 +152,6 @@ pub fn rules_for(language: Language) -> Rules {
quotes: vec![DOUBLE, SINGLE],
..Default::default()
},
// `###` opens and closes a block comment and `#` opens a line one,
// which is why the scanner tries the block opener first.
Language::Coffeescript => Rules {
keywords: words(KEYWORDS_COFFEESCRIPT),
line_comments: vec!["#"],
@@ -318,7 +288,6 @@ pub fn rules_for(language: Language) -> Rules {
keywords: words(KEYWORDS_SHELL),
line_comments: vec!["#"],
line_comments_at_word_start: true,
// A shell's single quotes are literal: `'a\'` is not one string.
quotes: vec![
DOUBLE,
Quote {
@@ -373,16 +342,10 @@ pub fn rules_for(language: Language) -> Rules {
attributes: Attributes::AtWord,
..Default::default()
},
// Markdown has no token rules; see `super::markdown::scan_markdown`.
Language::Markdown => Rules::default(),
}
}
// The keyword sets. Every list below other than RON, TOML, fish and JSON
// came from dev.snipme:highlights 1.1.0 (Apache-2.0), the library the
// Kotlin scanner replaced, so that no fence which was coloured there turns
// plain here either.
const KEYWORDS_C: &str =
"auto break case char const continue default do double else enum extern float for goto if
int long register return short signed sizeof static struct switch typedef union unsigned
@@ -416,9 +379,6 @@ const KEYWORDS_DART: &str =
required rethrow return sealed set show static super switch this throw true try var void
when with while yield";
/// fish is not in the library at all, so its fences are drawn plain today.
/// The list is the shell's own words, which is what a fish fence is mostly
/// made of.
const KEYWORDS_FISH: &str =
"and begin break builtin case command continue else end exec for function if in not or
return switch while set echo test string math read source";
@@ -466,7 +426,6 @@ const KEYWORDS_PYTHON: &str =
for from global if import in is lambda nonlocal not or pass raise return try while with
yield";
/// RON is not in the library either; these are the words a RON file can hold.
const KEYWORDS_RON: &str = "true false Some None inf NaN";
const KEYWORDS_RUBY: &str =
@@ -495,8 +454,6 @@ const KEYWORDS_SWIFT: &str =
nonmutating optional override postfix precedence prefix Protocol required right set some Type
unowned weak willSet";
/// TOML is not in the library; `inf` and `nan` are values rather than
/// names, like the booleans.
const KEYWORDS_TOML: &str = "true false inf nan";
const KEYWORDS_TYPESCRIPT: &str =
@@ -518,8 +475,6 @@ pub fn fence_language(name: Option<&str>) -> Option<Language> {
.map(|(_, language)| *language)
}
/// The highlighter's language for a *file*, from its name.
///
/// The extension is the part after the *last* dot, which is what makes
/// `build.gradle.kts` Kotlin. A leading dot is not one: `.bashrc` has no
/// extension, it has a name that starts with a dot. A name with no dot at
-62
View File
@@ -1,26 +1,8 @@
//! Markdown read into the spans that carry a colour -- a ```markdown fence
//! in a reply, and a `.md` file in the viewer. Ported from
//! `app/.../MarkdownSyntax.kt`; see that file's doc for why this is its own
//! scanner rather than a row of [`super::Rules`] (what a character means
//! depends on where it sits, not on what it is) and why an indented code
//! block is deliberately not recognised.
//!
//! Structure is read a line at a time and each line's prose left to right,
//! except the two decisions that are not: a fenced block is state carried
//! forward, and a table is found by its delimiter row, which comes after
//! the header it belongs to (the one place here that looks ahead).
use super::{Kind, Span};
/// The characters an unordered list may be bulleted with.
const BULLETS: &str = "-*+";
/// The characters a thematic break, or a setext heading's underline, can be
/// drawn with.
const RULE_MARKERS: &str = "-*_=";
/// The characters that can open emphasis, strong emphasis or a strikethrough.
const EMPHASIS: &str = "*_~";
/// Characters that end a bare URL wherever they appear, and ones only
/// trimmed off the end.
const URL_STOPS: &str = "<>\"'`|";
const URL_TRAILING: &str = ".,:;!?";
@@ -46,15 +28,10 @@ impl MarkdownScanner {
// The delimiter run that opened the fenced block we are inside, or
// None between them.
let mut fence: Option<Vec<char>> = None;
// Whether the row above was part of a table, which is what makes
// this one a body row.
let mut table = false;
loop {
let end = self.line_end(at);
if let Some(open) = fence.clone() {
// The content and the closing line alike: a fence is one
// block of code, and its own delimiters belong to it the
// way a string's quotes belong to the string.
self.emit(at, end, Kind::String);
if self.closes_fence(at, end, &open) {
fence = None;
@@ -76,7 +53,6 @@ impl MarkdownScanner {
self.spans
}
/// The end of the line beginning at `at`: the newline, or the end of the text.
fn line_end(&self, at: usize) -> usize {
self.code[at..]
.iter()
@@ -85,8 +61,6 @@ impl MarkdownScanner {
.unwrap_or(self.code.len())
}
/// One line that is not inside a fence, and whether the table it may be
/// part of is still open.
fn row(&mut self, start: usize, end: usize, table: bool) -> bool {
if self.table_delimiter(start, end) {
let indented = self.indented(start, end);
@@ -102,8 +76,6 @@ impl MarkdownScanner {
false
}
/// A line of nothing but pipes, dashes, alignment colons and space, with
/// one of each needed.
fn table_delimiter(&self, start: usize, end: usize) -> bool {
let mut dashes = false;
let mut pipes = false;
@@ -132,7 +104,6 @@ impl MarkdownScanner {
false
}
/// A table row: the pipes are the structure, and what is between them is prose.
fn table_row(&mut self, start: usize, end: usize) {
let mut at = self.indented(start, end);
let mut cell = at;
@@ -151,7 +122,6 @@ impl MarkdownScanner {
self.inline(cell, end);
}
/// Spans, coalesced with the one before when they touch and agree.
fn emit(&mut self, start: usize, end: usize, kind: Kind) {
if end <= start {
return;
@@ -166,7 +136,6 @@ impl MarkdownScanner {
self.spans.push(Span { start, end, kind });
}
/// The first character of the line at or after `start` that is not indentation.
fn indented(&self, start: usize, end: usize) -> usize {
let mut at = start;
while at < end && (self.code[at] == ' ' || self.code[at] == '\t') {
@@ -175,8 +144,6 @@ impl MarkdownScanner {
at
}
/// The run of backticks or tildes that could open or close a fence on
/// this line, or `None`.
fn fence_run(&self, start: usize, end: usize) -> Option<(usize, usize)> {
let at = self.indented(start, end);
if at == end {
@@ -193,20 +160,14 @@ impl MarkdownScanner {
if run - at >= 3 { Some((at, run)) } else { None }
}
/// Draws an opening fence line and answers its delimiter, or `None` if
/// this is not one.
fn opens_fence(&mut self, start: usize, end: usize) -> Option<Vec<char>> {
let (run_start, run_end) = self.fence_run(start, end)?;
self.emit(run_start, run_end, Kind::String);
// The info word is what the fence is a fence *of*, which is
// metadata about the block rather than part of it.
let indented = self.indented(run_end, end);
self.emit(indented, end, Kind::Metadata);
Some(self.code[run_start..run_end].to_vec())
}
/// Whether this line closes a fence opened by `open`: the same
/// character, at least as many of them, and nothing else on the line.
fn closes_fence(&self, start: usize, end: usize, open: &[char]) -> bool {
let Some((run_start, run_end)) = self.fence_run(start, end) else {
return false;
@@ -217,12 +178,8 @@ impl MarkdownScanner {
self.indented(run_end, end) == end
}
/// One ordinary line: what its opening characters make it, and then its prose.
fn structure(&mut self, start: usize, end: usize) {
let mut at = start;
// Quote markers come before everything else and can be several
// deep, and what follows one is an ordinary line again -- a heading
// inside a quote is still a heading.
while at < end && self.code[at] == '>' {
at += 1;
self.emit(at - 1, at, Kind::Mark);
@@ -238,8 +195,6 @@ impl MarkdownScanner {
self.inline(text_start, end);
}
/// `#` to `######` and a space. Without the space it is a word
/// beginning with a hash.
fn heading(&mut self, start: usize, end: usize) -> bool {
let mut at = start;
while at < end && self.code[at] == '#' {
@@ -256,7 +211,6 @@ impl MarkdownScanner {
true
}
/// A line made of one repeated rule character and nothing else.
fn thematic_break(&mut self, start: usize, end: usize) -> bool {
let marker = self.code[start];
if !RULE_MARKERS.contains(marker) {
@@ -277,8 +231,6 @@ impl MarkdownScanner {
true
}
/// Draws a list marker if the line opens with one, and answers where
/// the item's text starts.
fn bullet(&mut self, start: usize, end: usize) -> usize {
let marker = self.code[start];
if BULLETS.contains(marker) && self.space_or_end(start + 1, end) {
@@ -304,16 +256,11 @@ impl MarkdownScanner {
at >= end || self.code[at] == ' ' || self.code[at] == '\t'
}
/// The inline forms, left to right. Every branch answers a position
/// strictly after `start` of its call, so this terminates.
fn inline(&mut self, start: usize, end: usize) {
let mut at = start;
while at < end {
let c = self.code[at];
at = if c == '\\' {
// A backslash takes the character after it out of the
// running entirely, which is how `\*` stays an asterisk
// rather than opening emphasis.
at + 2
} else if c == '`' {
self.code_span(at, end)
@@ -331,7 +278,6 @@ impl MarkdownScanner {
}
}
/// `` `code` ``, closed by a run of exactly as many backticks as opened it.
fn code_span(&mut self, start: usize, end: usize) -> usize {
let mut open = start;
while open < end && self.code[open] == '`' {
@@ -354,11 +300,9 @@ impl MarkdownScanner {
}
at = close;
}
// Nothing closes it on this line, so those were ordinary backticks.
open
}
/// `[text](destination)`, and the same with a leading `!` for an image.
fn link(&mut self, start: usize, bracket: usize, end: usize) -> usize {
let mut depth = 0i32;
let mut close = bracket;
@@ -397,8 +341,6 @@ impl MarkdownScanner {
paren + 1
}
/// `<https://example.com>` and `<name@example.com>`, drawn as the
/// destination they are.
fn autolink(&mut self, start: usize, end: usize) -> usize {
let mut at = start + 1;
let mut addressed = false;
@@ -422,8 +364,6 @@ impl MarkdownScanner {
start + 1
}
/// A bare `scheme://...` written in prose, or `None` if one does not
/// start here.
fn url(&mut self, start: usize, end: usize) -> Option<usize> {
if start > 0 && is_word(self.code[start - 1]) {
return None;
@@ -465,8 +405,6 @@ impl MarkdownScanner {
Some(at)
}
/// `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and
/// all.
fn emphasis(&mut self, start: usize, end: usize) -> usize {
let marker = self.code[start];
let mut open = start;
-48
View File
@@ -1,24 +1,3 @@
//! `code` read once, left to right, into the spans that carry a colour.
//! Ported from `app/.../Highlighter.kt`.
//!
//! One pass with a small state -- in a comment, in a string, or in ordinary
//! code -- rather than a locator per token kind over the whole text, which
//! is what the library this replaced did and is why it found comments
//! before it knew the language: a `#` inside a shell string, a `//` inside
//! a URL and a block-comment opener inside a shell glob each commented out
//! the rest of a line that was nothing of the sort.
//!
//! Every span is produced by advancing an index forward, so the result is
//! ordered, non-overlapping and inside the code by construction. Nothing
//! here panics: an unterminated string or comment runs to the end of the
//! code, which is also what it looks like while a fence is still being
//! written.
//!
//! **Indices are char offsets, not byte offsets** -- the scanner works over
//! `Vec<char>`, mirroring the Kotlin original's `Char`-indexed strings, so
//! [`span_text`] is how a caller (and every test here) turns a [`Span`]
//! back into the text it covers.
pub mod languages;
pub mod markdown;
@@ -26,7 +5,6 @@ pub use languages::{
Attributes, BlockComment, Language, Quote, Rules, fence_language, file_language, rules_for,
};
/// What a span of code is, in the terms a palette has a colour for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Kind {
Keyword,
@@ -38,7 +16,6 @@ pub enum Kind {
Mark,
}
/// A run of [`Kind`] in the code, as a half-open range of **char** indices.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span {
pub start: usize,
@@ -70,8 +47,6 @@ pub fn scan(code: &str, rules: &Rules) -> Vec<Span> {
Scanner::new(code, rules).run()
}
/// Characters coloured as punctuation, and as marks. Both sets are the ones
/// the library this replaced used.
const PUNCTUATION: &str = ",.:;";
const MARKS: &str = "()={}<>-+[]|&";
@@ -94,8 +69,6 @@ impl<'a> Scanner<'a> {
fn run(mut self) -> Vec<Span> {
while self.at < self.code.len() {
// Every branch that answers true has advanced `self.at`, so
// this terminates.
let consumed = self.block_comment()
|| self.line_comment()
|| self.raw_string()
@@ -126,15 +99,12 @@ impl<'a> Scanner<'a> {
starts_with_at(&self.code, self.at, token)
}
/// Whether a line comment token here opens one; see
/// [`Rules::line_comments_at_word_start`].
fn at_word_start(&self) -> bool {
self.at == 0
|| self.code[self.at - 1].is_whitespace()
|| ";|&(".contains(self.code[self.at - 1])
}
/// Whether only whitespace stands between the start of this line and here.
fn at_line_start(&self) -> bool {
let mut back = self.at as isize - 1;
while back >= 0 && self.code[back as usize] != '\n' {
@@ -152,8 +122,6 @@ impl<'a> Scanner<'a> {
}
}
/// From an open bracket through the one that matches it, or to the end
/// if none does.
fn advance_to_matching_bracket(&mut self) {
let mut depth = 0i32;
while self.at < self.code.len() {
@@ -180,9 +148,6 @@ impl<'a> Scanner<'a> {
self.at += comment.open.chars().count();
let mut depth = 1i32;
while self.at < self.code.len() && depth > 0 {
// The closer is tried first so that a language whose two
// delimiters are the same string -- CoffeeScript's `###` --
// closes rather than nesting forever.
if self.starts(comment.close) {
depth -= 1;
self.at += comment.close.chars().count();
@@ -210,7 +175,6 @@ impl<'a> Scanner<'a> {
true
}
/// Rust and RON: `b`? `r` `#`* `"` ... `"` `#`*, with no escapes inside.
fn raw_string(&mut self) -> bool {
if !self.rules.raw_strings {
return false;
@@ -267,8 +231,6 @@ impl<'a> Scanner<'a> {
}
fn string(&mut self) -> bool {
// Longest opener wins, so Kotlin's `"""` is one delimiter rather
// than an empty string followed by a quote.
let mut quote: Option<Quote> = None;
for candidate in &self.rules.quotes {
let current_len = quote.map(|q| q.open.chars().count()).unwrap_or(0);
@@ -346,9 +308,6 @@ impl<'a> Scanner<'a> {
true
}
/// A number is a run starting with a digit and carrying on through
/// letters, digits, `_` and `.` -- which covers `0xFF`, `1_000`, `1u32`
/// and `3.14` without a grammar for any of them.
fn number(&mut self) -> bool {
if !self.code[self.at].is_ascii_digit() {
return false;
@@ -403,7 +362,6 @@ fn is_word_part(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
/// Whether `code[at..]` starts with `token`, both read as chars.
fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
let token: Vec<char> = token.chars().collect();
if at + token.len() > code.len() {
@@ -412,8 +370,6 @@ fn starts_with_at(code: &[char], at: usize, token: &str) -> bool {
code[at..at + token.len()] == token[..]
}
/// The first index at or after `from` where `code` contains `needle`, or
/// `None`.
fn find_from(code: &[char], from: usize, needle: &[char]) -> Option<usize> {
if needle.is_empty() || from > code.len() {
return None;
@@ -640,10 +596,6 @@ mod tests {
}
}
/// The scanner must never panic and must never answer a span the code
/// does not contain: the library this replaced answered a reversed
/// range here, which crashed a card, and a fence still being written is
/// an unterminated string or comment on every keystroke.
#[test]
fn spans_stay_inside_the_code_for_every_language_and_every_nasty_input() {
let nasty = [
-114
View File
@@ -1,33 +1,7 @@
//! The app's own recent log, held in memory so it can be read back
//! without `logcat`.
//!
//! **Why this exists**: Iris tests iris builds on a GrapheneOS phone with
//! no `adb`, and Android forbids one app reading another's logcat, so
//! nothing outside the process can recover what it wrote. The only way a
//! line reaches her is for the app to carry its own copy. This is that
//! copy: a bounded ring every `log::info!` in the process lands in, on top
//! of whichever platform logger was already installed (`android_logger`,
//! `env_logger`) rather than instead of it -- see [`RingLogger`].
//!
//! Three consumers, all reading the same ring rather than each keeping
//! their own: whatever hands the log out of the process -- on Android, the
//! `DevLogProvider` Dev Updater queries, which reads [`LogRing::since`]
//! and [`LogRing::newest_seq`] -- the bench app's diagnostics pane, which
//! only counts it ([`LogRing::summary`]), and the panic hook
//! ([`LogRing::try_tail_text`]). That is why reading does not consume: a
//! line already handed over must still be readable, and a report taken
//! twice must say the same thing.
//!
//! Nothing inlines the log into a copied report any more (2026-09-08):
//! Dev Updater reads it directly, so a second copy on the clipboard was
//! the same lines twice.
use std::collections::VecDeque;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
/// How many lines a default ring holds, and how many bytes of message.
///
/// Both bounds apply -- whichever bites first -- because the two failure
/// modes are different: a flood of short lines exhausts the count, and one
/// pathological line (a stack trace, a pretty-printed JSON body) exhausts
@@ -36,10 +10,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
pub const DEFAULT_MAX_LINES: usize = 2000;
pub const DEFAULT_MAX_BYTES: usize = 256 * 1024;
/// One recorded line. `seq` is assigned by the ring and only ever
/// increases, so a reader that remembers where it got to can ask for what
/// came after -- and a gap in the sequence is exactly the lines the bound
/// dropped.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogLine {
pub seq: u64,
@@ -53,9 +23,6 @@ pub struct LogLine {
}
impl LogLine {
/// Roughly what the line costs the ring. The two `String`s dominate;
/// the fixed fields are counted as a flat overhead so a ring of empty
/// messages still has a bound.
fn weight(&self) -> usize {
self.target.len() + self.message.len() + 32
}
@@ -74,11 +41,6 @@ impl LogLine {
}
}
/// `HH:MM:SS.mmm` in UTC from a unix millisecond count, without a date
/// library: the only field this needs is the time of day, and dividing out
/// the day is the whole calculation. Deliberately not local time -- the
/// phone's offset is not knowable here, and a report that says UTC is
/// comparable with the server's log, which is what it gets read against.
fn clock_time(at_ms: u64) -> String {
let ms = at_ms % 1000;
let secs_of_day = (at_ms / 1000) % 86_400;
@@ -108,15 +70,9 @@ struct Inner {
max_lines: usize,
max_bytes: usize,
next_seq: u64,
/// How many lines the bounds have discarded since the ring was made.
/// Reported rather than inferred, so "the log starts here" and "the
/// log was cut off here" are distinguishable -- the unknown state the
/// UI rules ask for.
dropped: u64,
}
/// A bounded, shareable ring of recent log lines. Cloning shares the ring;
/// there is one per process and every holder sees the same lines.
#[derive(Debug, Clone)]
pub struct LogRing(Arc<Mutex<Inner>>);
@@ -136,8 +92,6 @@ impl LogRing {
})))
}
/// The bounds this project ships with: [`DEFAULT_MAX_LINES`] and
/// [`DEFAULT_MAX_BYTES`].
pub fn with_defaults() -> Self {
Self::new(DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES)
}
@@ -153,7 +107,6 @@ impl LogRing {
f(&mut guard)
}
/// Records a line, evicting the oldest until both bounds hold again.
pub fn push(&self, level: log::Level, target: &str, message: String) {
self.with(|inner| {
let line = LogLine {
@@ -180,14 +133,10 @@ impl LogRing {
})
}
/// Every line held, oldest first.
pub fn snapshot(&self) -> Vec<LogLine> {
self.with(|inner| inner.lines.iter().cloned().collect())
}
/// The lines with a sequence number at or after `seq`, oldest first,
/// and the sequence to ask from next time. Does not consume: see this
/// module's doc for why.
pub fn since(&self, seq: u64) -> (Vec<LogLine>, u64) {
self.with(|inner| {
let lines: Vec<LogLine> = inner
@@ -234,8 +183,6 @@ impl LogRing {
self.with(|inner| inner.lines.back().map(|line| line.at_ms))
}
/// Every line held, formatted one per line -- what `Copy report`
/// appends.
pub fn to_text(&self) -> String {
self.snapshot()
.iter()
@@ -258,8 +205,6 @@ impl LogRing {
pub fn try_tail_text(&self, max_lines: usize) -> Option<String> {
let guard = match self.0.try_lock() {
Ok(guard) => guard,
// A poisoned lock is uncontended, so its contents are still
// readable -- the same judgement as `with`.
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
Err(std::sync::TryLockError::WouldBlock) => return None,
};
@@ -317,9 +262,6 @@ fn is_own_target(target: &str) -> bool {
|| target.starts_with("ai_app::")
}
/// Whether a line at `level` from `target` belongs in the ring, given
/// whether tracing is on right now.
///
/// This is the one filter docs/IRIS_TODO.md's "logs way too big" entry
/// asked for, applied once here rather than at each `debug!` call site:
/// Info and above always ring, from anything, because a real warning or
@@ -336,24 +278,9 @@ fn ring_accepts(level: log::Level, target: &str, trace_enabled: bool) -> bool {
level <= log::Level::Info || (trace_enabled && is_own_target(target))
}
/// A `log` backend that records into a [`LogRing`] **and** forwards to the
/// logger the platform already installs, so nothing that reads the
/// platform's log (`logcat`, a terminal) changes.
///
/// The inner logger is passed in rather than chosen here: `client-core`
/// has no business depending on `android_logger` or `env_logger`, and
/// which one is right is exactly what differs between the two platforms
/// (the sharing rule in AGENTS.md).
pub struct RingLogger {
ring: LogRing,
inner: Box<dyn log::Log>,
/// Whether `iris::input`/`iris::frame`-style tracing is switched on
/// right now, consulted by [`ring_accepts`]. A plain fn pointer rather
/// than a dependency on `iris::diagnostics::trace_enabled` directly:
/// `client-core` sits below `iris` (AGENTS.md's "dependencies flow one
/// direction"), so the platform crate that depends on both is the one
/// that wires this closure through, the same way it already supplies
/// `inner`.
trace_enabled: fn() -> bool,
}
@@ -368,10 +295,6 @@ impl RingLogger {
}
impl log::Log for RingLogger {
/// True for anything `log`'s own max level lets through: the ring
/// wants everything the *inner* logger might also want, even where the
/// platform logger would filter it out. Which lines the ring itself
/// keeps is decided in [`Self::log`] by [`ring_accepts`].
fn enabled(&self, _metadata: &log::Metadata) -> bool {
true
}
@@ -391,9 +314,6 @@ impl log::Log for RingLogger {
}
}
/// Installs a [`RingLogger`] as the process logger and answers the ring it
/// records into.
///
/// Fails only if a logger is already installed, which is a programmer
/// error (two initialisation paths) rather than a recoverable condition --
/// the caller is named in the error so it is findable.
@@ -408,8 +328,6 @@ pub fn install(
Ok(())
}
/// The one ring this process records into.
///
/// **A deliberate process-global, where this project's rules otherwise say
/// pass context explicitly.** What is being modelled is already one: `log`
/// has exactly one backend per process, set once, and every `log::info!`
@@ -421,20 +339,10 @@ pub fn install(
/// testable.
static PROCESS_RING: OnceLock<LogRing> = OnceLock::new();
/// The process's ring, created on first use with the default bounds.
/// Safe to call before [`install_process_logger`] -- it will simply be
/// empty.
pub fn process_ring() -> &'static LogRing {
PROCESS_RING.get_or_init(LogRing::with_defaults)
}
/// Installs [`process_ring`] as the recording half of the process logger,
/// forwarding to `inner` (the platform's own logger, already configured).
/// The platform half of AGENTS.md's sharing rule is `inner`; everything
/// else is shared. `trace_enabled` is the platform's own trace toggle
/// (`iris::diagnostics::trace_enabled` on Android) -- see
/// [`ring_accepts`] and the field doc on `RingLogger` for why it is
/// passed in rather than called directly.
pub fn install_process_logger(
inner: Box<dyn log::Log>,
max_level: log::LevelFilter,
@@ -474,7 +382,6 @@ mod tests {
#[test]
fn the_byte_bound_bites_before_the_line_bound_when_lines_are_large() {
// Room for 1000 lines but only a few hundred bytes.
let ring = LogRing::new(1000, 300);
for n in 0..10 {
ring.push(Level::Info, "t", format!("{n}{}", "x".repeat(100)));
@@ -491,9 +398,6 @@ mod tests {
);
}
/// The case the `len() > 1` guard exists for: one line larger than the
/// whole bound must still be readable, or a ring that is over budget
/// reads as a ring nothing was written to.
#[test]
fn one_oversized_line_is_kept_rather_than_leaving_the_ring_empty() {
let ring = LogRing::new(100, 64);
@@ -529,8 +433,6 @@ mod tests {
assert_eq!(cursor, 4);
}
/// The restart signal: a reader that saw sequence 4 and is now told
/// the newest is 0 knows the process is not the one it was reading.
#[test]
fn the_newest_sequence_says_where_the_ring_is_and_nothing_for_an_empty_one() {
let ring = LogRing::new(100, 1 << 20);
@@ -568,9 +470,6 @@ mod tests {
assert!(lines[79].ends_with("line 199"), "{}", lines[79]);
}
/// The whole point of the `try_`: the panic hook calls this from a
/// thread that may already hold the ring's lock, and a blocking read
/// there would hang the process instead of aborting it.
#[test]
fn try_tail_text_answers_none_rather_than_blocking_on_a_held_lock() {
let ring = LogRing::new(10, 1 << 20);
@@ -602,8 +501,6 @@ mod tests {
fn a_line_formats_as_time_level_target_message() {
let line = LogLine {
seq: 0,
// 1970-01-01T12:34:56.789Z, so the arithmetic is checkable by
// hand rather than against another clock.
at_ms: (12 * 3600 + 34 * 60 + 56) * 1000 + 789,
level: Level::Info,
target: "iris::android".into(),
@@ -613,9 +510,6 @@ mod tests {
assert_eq!(line, "12:34:56.789 INFO iris::android: surface created");
}
/// The forwarding half: a line reaches the ring *and* the logger the
/// platform already had, and one the inner logger filters out is still
/// in the ring.
#[test]
fn the_ring_logger_forwards_to_the_inner_logger() {
use log::Log;
@@ -632,9 +526,6 @@ mod tests {
let seen = Arc::new(Mutex::new(Vec::new()));
let ring = LogRing::with_defaults();
// Own target, tracing on: this is the case where the ring and the
// inner logger disagree, which is the thing under test -- a
// foreign target is covered separately below.
let logger = RingLogger::new(
ring.clone(),
Box::new(Collect(seen.clone(), Level::Info)),
@@ -668,11 +559,6 @@ mod tests {
);
}
/// The bug this filter fixes: `naga`/`wgpu_core`/`jni` log at Debug
/// unconditionally, and used to flood the ring even though nothing in
/// this app asked for their Debug output. A foreign target's Debug
/// line must not ring even while tracing is on -- tracing controls
/// this app's own diagnostics, not a dependency's chatter.
#[test]
fn a_foreign_targets_debug_line_never_rings_even_while_tracing_is_on() {
use log::Log;
-84
View File
@@ -1,33 +1,3 @@
//! Split a markdown message into its top-level **blocks** -- one
//! paragraph, heading, fenced code block, list, table or quote each, as a
//! byte slice of the original source.
//!
//! This exists for streaming. A transcript row used to be one text widget
//! holding the whole message, so a single streamed delta re-shaped every
//! paragraph of it through the text engine again; the phone's bench v2 put
//! the stream phase at p50 18.2ms against Compose's 13.4ms for exactly
//! that reason (docs/IRIS_TODO.md). A row is a column of one widget per
//! block now, and a delta that lands in the last block leaves every
//! earlier block's layout alone. the 2026-09-06 decision has
//! what that rejected and why the split lives here rather than in the UI
//! crate: `docs/CLIENT_CORE.md` already wanted a block model for P1, and
//! keeping it here means iris stays a text renderer that knows nothing
//! about markdown.
//!
//! **Blocks only.** Inline styling (bold, links, inline code) is still the
//! renderer's own job, per block -- this deliberately does not build a
//! full AST, because nothing needs one yet.
//!
//! ## Appending is not guaranteed to leave earlier blocks alone
//!
//! It nearly always does, which is what makes the fast path worth having,
//! but markdown has no such rule: appending a "```" line can turn text
//! that was three paragraphs into one fenced block, and appending "---"
//! under a paragraph turns that paragraph into a heading. So a caller
//! taking the O(last block) path **must compare the prefix it is about to
//! keep** rather than assume it. [`common_prefix`] is that comparison, and
//! it is cheap next to laying the text out again.
use pulldown_cmark::{Event, Options, Parser, Tag};
/// What a block is, for a renderer that wants to style or space blocks
@@ -40,20 +10,13 @@ use pulldown_cmark::{Event, Options, Parser, Tag};
pub enum BlockKind {
Paragraph,
Heading,
/// A fenced or indented code block.
Code,
List,
Table,
Quote,
/// A thematic break, raw HTML, a footnote -- anything with no
/// distinguished treatment here.
Other,
}
/// One top-level block: its kind and the exact source that produced it.
/// `source` is a slice of the input with trailing whitespace removed, so
/// two splits of the same prefix compare equal even when one of them had a
/// delta arriving after it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Block {
pub kind: BlockKind,
@@ -73,16 +36,9 @@ fn kind_of(tag: &Tag) -> BlockKind {
}
fn options() -> Options {
// The same set `transcript-ui`'s renderer parses with, so a block
// boundary here and the styling there cannot disagree about what the
// source means.
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS
}
/// Split `src` into its top-level blocks, in source order. An empty or
/// whitespace-only input gives no blocks; text the parser does not put
/// inside any block (a stray fence marker mid-stream) still comes back,
/// as `Other`, rather than being dropped.
pub fn split_blocks(src: &str) -> Vec<Block> {
let mut out: Vec<Block> = Vec::new();
let mut depth = 0usize;
@@ -101,9 +57,6 @@ pub fn split_blocks(src: &str) -> Vec<Block> {
push(&mut out, kind, &src[range]);
}
}
// A top-level event that is not part of any block -- a
// thematic break, a block of raw HTML. Inside one, it is the
// enclosing block's business and this does nothing.
_ => {
if depth == 0 {
push(&mut out, BlockKind::Other, &src[range]);
@@ -163,9 +116,6 @@ mod tests {
assert!(split_blocks(" \n\n ").is_empty());
}
/// The property the streaming fast path rests on, in its ordinary
/// shape: a delta landing in the last paragraph must leave every
/// earlier block byte-identical.
#[test]
fn a_delta_into_the_last_paragraph_leaves_earlier_blocks_untouched() {
let before = split_blocks("# Title\n\nFirst para.\n\nSecond par");
@@ -176,9 +126,6 @@ mod tests {
assert_ne!(before[2], after[2]);
}
/// A delta that starts a *new* block keeps every old block, including
/// the one that was last -- so the fast path appends rather than
/// replacing.
#[test]
fn a_delta_that_starts_a_new_block_keeps_every_old_one() {
let before = split_blocks("First para.\n\nSecond para.");
@@ -187,10 +134,6 @@ mod tests {
assert_eq!(after.len(), 3);
}
/// A code fence arrives one delta at a time and is unterminated for
/// most of its life. It must still be *one* block the whole way, or
/// every delta would re-split the message into a different number of
/// pieces.
#[test]
fn an_unterminated_fence_is_one_block_while_it_streams() {
for src in [
@@ -206,11 +149,6 @@ mod tests {
}
}
/// The half the fast path had no reason to touch, and the reason
/// `common_prefix` is a comparison rather than an assumption:
/// appending can rewrite what came before. `---` under a paragraph
/// turns that paragraph into a setext heading, so the block that was
/// already laid out is not the block it is now.
#[test]
fn appending_can_rewrite_an_earlier_block_and_the_prefix_says_so() {
let before = split_blocks("Not a heading\n\nsecond");
@@ -232,12 +170,6 @@ mod tests {
);
}
/// The shapes a real transcript actually contains, each checked for
/// the one property the streaming fast path needs: the *number* of
/// blocks and every earlier block's source stay put while the message
/// grows. A fence's own blank lines, a `---` inside one, a nested
/// list and a table are all places where a naive line-based split
/// would break the message into more pieces than there are blocks.
#[test]
fn the_transcripts_own_block_shapes_survive_a_split() {
let fence_with_blanks = "Intro.\n\n```rust\nfn a() {}\n\nfn b() {}\n```\n\nAfter.";
@@ -271,19 +203,9 @@ mod tests {
);
}
/// `apply_delta`'s precondition, stated as the property rather than
/// the arithmetic: for every prefix of a realistic streamed message,
/// the blocks before the last one must be exactly the blocks the
/// previous prefix had. Where markdown breaks that (the `---` case
/// above), `common_prefix` has to *say* so -- which is what the
/// `>= len - 1` assertion below checks: the split may rewrite the
/// last block, never an earlier one, or `RowBlocks::apply_delta`
/// would keep a widget whose text is no longer what it holds.
#[test]
fn every_prefix_of_a_streamed_message_keeps_all_but_its_last_block() {
let full = "# Report\n\nFirst finding, at some length.\n\n```rust\nfn main() {\n\n println!(\"hi\");\n}\n```\n\n- one\n - nested\n- two\n\n| a | b |\n |---|---|\n| 1 | 2 |\n\n> and a closing quote.";
// Every character boundary, so a delta landing mid-word and one
// landing exactly on a fence's closing backtick are both covered.
let mut prev = Vec::new();
for end in full.char_indices().map(|(i, _)| i).chain([full.len()]) {
let now = split_blocks(&full[..end]);
@@ -297,9 +219,6 @@ mod tests {
}
}
/// The half a growing message cannot show: a fence that never closes.
/// The stream ends there and the block must still be the code block
/// it has been all along, not re-split into paragraphs.
#[test]
fn a_stream_that_ends_inside_a_fence_still_ends_with_one_code_block() {
let src = "Here is the patch:\n\n```diff\n- old line\n+ new line";
@@ -311,9 +230,6 @@ mod tests {
assert_eq!(blocks[1].source, "```diff\n- old line\n+ new line");
}
/// A delta that closes a fence changes the *last* block only, so the
/// fast path takes it -- the case the module doc says is the reason
/// `common_prefix` is a comparison.
#[test]
fn the_delta_that_closes_a_fence_changes_only_the_last_block() {
let before = split_blocks("Text.\n\n```\ncode\n");
-4
View File
@@ -1,7 +1,3 @@
//! The app's pure logic, shared between the server and any Rust client --
//! see `docs/CLIENT_CORE.md` for what lives here and what does
//! not yet.
pub mod ansi;
pub mod api;
pub mod config;
-13
View File
@@ -4,14 +4,6 @@
//! ([`crate::client::sse`]) and the wire shape ([`SessionNotification`],
//! [`NotificationKind`], mirroring `server/src/session/mod.rs`'s
//! `Notification`/`NotificationKind`).
//!
//! What is deliberately **not** here, because it is a decision rather than
//! logic: whether a given notification is shown at all (the session on
//! screen gets nothing), handed to the app as a banner, or posted to the
//! platform's own notification drawer. That three-way choice reads
//! process-wide state (what screen is open, whether the app is in front)
//! that has no meaning to a pure crate with no UI and no Android in it --
//! see `android-shell` for where it lives for this port.
use std::io::{BufRead, BufReader};
@@ -20,8 +12,6 @@ use serde::Deserialize;
use crate::client::api::{ApiError, Transport};
use crate::client::sse::SseReader;
/// One frame of `GET /notifications`, matching `server/src/session/mod.rs`'s
/// `Notification` field for field.
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionNotification {
@@ -32,9 +22,6 @@ pub struct SessionNotification {
pub at: f64,
}
/// Mirrors `server/src/session/mod.rs`'s `NotificationKind` -- serialized
/// the same way, so this deserializes the wire's `"awaitingInput"` /
/// `"finished"` directly rather than through a string match.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum NotificationKind {
-19
View File
@@ -1,15 +1,3 @@
//! Server-sent-events framing, ported from `app/.../Sse.kt`: `data:` and
//! `event:` lines accumulate until a blank line ends the frame, comments
//! start with `:`, and a frame is either named with no payload or a payload
//! with no name.
//!
//! Pure and line-at-a-time, unlike the Kotlin original which also owned the
//! socket: `server/routes.rs`'s SSE bodies are one event per line, so a
//! caller here feeds lines from wherever they came from (a real connection,
//! a test fixture) and gets frames back with no I/O of its own -- which is
//! what lets this be tested with no server, per RUST.md's "pure logic
//! first" for this crate.
/// One SSE frame: its name (`None` for an ordinary data frame) and its payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Frame {
@@ -17,10 +5,6 @@ pub struct Frame {
pub data: String,
}
/// Accumulates lines into [`Frame`]s. One instance per connection --
/// `feed_line` is called for every line the transport reads (with line
/// endings already stripped), and answers a frame when a blank line closes
/// one.
#[derive(Debug, Default)]
pub struct SseReader {
data: String,
@@ -32,8 +16,6 @@ impl SseReader {
Self::default()
}
/// Feeds one line (no trailing `\n`). Answers the frame this line
/// completed, if any.
pub fn feed_line(&mut self, line: &str) -> Option<Frame> {
if line.is_empty() {
if self.name.is_some() || !self.data.is_empty() {
@@ -50,7 +32,6 @@ impl SseReader {
} else if let Some(rest) = line.strip_prefix("event:") {
self.name = Some(rest.trim().to_string());
}
// `id:`, comments -- nothing to do.
None
}
}
-41
View File
@@ -1,50 +1,17 @@
//! How much of a long thing a transcript draws before offering the rest
//! behind a tap.
//!
//! One rule, four surfaces: a tool call's input, its output, and a user or
//! assistant message. It lives here rather than at any one of them because
//! four copies would eventually disagree about what "too long" is, and
//! because the Compose app has to answer the same question the same way --
//! `TextCap.kt` is the Kotlin half, and the two are checked against the
//! same numbers so a benchmark comparing the apps is comparing renderers
//! rather than policies.
//!
//! **Lines and bytes both, whichever runs out first**, because they run
//! out on different things: a diff is thousands of short lines, a minified
//! file or a base64 blob is one enormous one, and a cap that only counted
//! one of them draws the whole of the other.
//!
//! **Cut at the head, keeping the beginning.** A tool's output is read
//! from the top and the line saying what went wrong is nearly always the
//! first; a message is read from the top for the obvious reason. (A path
//! is identified by its other end -- none of these is a path.)
/// The default bound on a verbatim block -- a tool call's input or its
/// output. Short, because this text is a machine's and the reader is
/// looking for one line of it.
pub const VERBATIM_LINES: usize = 80;
pub const VERBATIM_BYTES: usize = 4096;
/// The bound on a message, a person's or the model's. Larger than a
/// verbatim block's in bytes and smaller in lines: prose is read whole and
/// wraps, so a screenful of it is far fewer lines than a screenful of a
/// log, and cutting a reply at 80 lines would cut most long answers that
/// nobody would call long.
pub const MESSAGE_LINES: usize = 200;
pub const MESSAGE_BYTES: usize = 16 * 1024;
/// A cap of nothing would draw an empty panel and a "Show all" for
/// everything there is, which reads as a rendering fault rather than as a
/// cap. Checked at compile time, since all four are constants.
const _: () = assert!(VERBATIM_LINES > 0 && VERBATIM_BYTES > 0);
const _: () = assert!(MESSAGE_LINES > 0 && MESSAGE_BYTES > 0);
/// `text` cut to `max_lines` lines and `max_bytes` bytes, with the line
/// count it was cut *from*; `None` when the whole of it fits.
///
/// The count is the whole text's, not the shown part's -- it is what the
/// "Show all N lines" offer says, and a reader deciding whether to ask for
/// the rest wants to know how much the rest is.
pub fn cut(text: &str, max_lines: usize, max_bytes: usize) -> Option<(&str, usize)> {
debug_assert!(
max_lines > 0 && max_bytes > 0,
@@ -72,8 +39,6 @@ pub fn cut(text: &str, max_lines: usize, max_bytes: usize) -> Option<(&str, usiz
Some((&text[..cut], text.lines().count()))
}
/// What a "Show all" offer says, so the wording is one string rather than
/// one per surface.
pub fn show_all_label(lines: usize) -> String {
format!("Show all {lines} lines")
}
@@ -98,8 +63,6 @@ mod tests {
);
}
/// The half the line bound cannot catch: one enormous line, which is
/// what a minified file or an embedded image arrives as.
#[test]
fn the_byte_bound_cuts_one_long_line() {
let text = "x".repeat(5000);
@@ -108,7 +71,6 @@ mod tests {
assert_eq!(lines, 1);
}
/// Whichever bites first, rather than whichever was checked first.
#[test]
fn the_tighter_of_the_two_bounds_wins() {
let text = "aaaa\n".repeat(100);
@@ -118,9 +80,6 @@ mod tests {
assert_eq!(shown, "aaaa\naaaa\naaaa\naaaa");
}
/// A cut that lands inside a multi-byte character has to back up to
/// the boundary; slicing there would panic, and a transcript carries
/// em dashes and box drawing in every other line.
#[test]
fn a_cut_inside_a_multibyte_character_backs_up_to_the_boundary() {
let text = "é".repeat(100);
-58
View File
@@ -1,31 +1,11 @@
//! A tool call's input, read rather than dumped -- the port of
//! `ToolInput.kt`'s `parseToolInput`, which is what both the collapsed
//! card's one-line summary and the expanded card's key/value list are
//! derived from.
//!
//! Every tool's input arrives as JSON, and showing it raw makes the reader
//! parse `{"command":"…","timeout":120000}` themselves to find the one
//! line they care about. So the fields that carry the meaning are pulled
//! out, and anything left over is still shown, because dropping a field
//! would be claiming the tool has no other input when it might.
//!
//! Pure, and here rather than in the widget crate, for the reason the rest
//! of this crate exists: the derivation is the same on a phone and on a
//! desktop, and it is testable without a renderer.
use crate::client::durations::format_millis_text;
use crate::client::highlight::Language;
use serde_json::{Map, Value};
/// A tool call's input, split into the parts a card draws separately.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ToolInput {
/// The thing that will actually be run or read, if this tool has one.
pub subject: Option<String>,
/// The language [`ToolInput::subject`] is written in, for
/// highlighting.
pub language: Option<Language>,
/// The tool's own one-line summary, when it wrote one.
pub description: Option<String>,
/// How long the call may take, in the largest units it fits. Shown
/// apart because it is a limit on the call rather than part of what
@@ -36,25 +16,14 @@ pub struct ToolInput {
}
impl ToolInput {
/// The one line to show when there is only room for one: what this
/// call is for.
pub fn title(&self) -> Option<&str> {
self.description
.as_deref()
.or(self.subject.as_deref())
// A subject that is only whitespace would draw as an empty
// summary line, which reads as a tool with nothing to say
// rather than as one whose subject was blank.
.filter(|t| !t.trim().is_empty())
}
}
/// Which field of which tool is the subject.
///
/// A table rather than a chain of `if`s: adding a tool is a row, and the
/// shape stops any of them from being the special case that gets its own
/// code path. Unknown tools fall through to "no subject, everything is
/// rest".
const SUBJECTS: &[(&str, &str, Option<Language>)] = &[
("Bash", "command", Some(Language::Shell)),
("Read", "file_path", None),
@@ -65,13 +34,8 @@ const SUBJECTS: &[(&str, &str, Option<Language>)] = &[
("WebFetch", "url", None),
];
/// Fields that are the tool's own prose about itself rather than input to
/// it.
const DESCRIPTIONS: &[&str] = &["description", "prompt"];
/// One JSON value as the Kotlin's `JSONObject.optString`/`get` wrote it: a
/// string is its own characters, anything else is its JSON form.
///
/// One function rather than two, because the same coercion decides both
/// what a subject reads as and what a leftover field's value reads as, and
/// two copies would eventually disagree about a number.
@@ -87,11 +51,6 @@ fn non_blank(value: Option<&Value>) -> Option<String> {
(!text.trim().is_empty()).then_some(text)
}
/// Split `input` (a tool call's JSON) into the parts a card draws.
///
/// Input that is not a JSON object -- older transcripts and some tools
/// send a bare string -- is still the input, so it is still shown, as the
/// whole of `rest`.
pub fn parse_tool_input(tool: &str, input: &str) -> ToolInput {
let Ok(Value::Object(json)) = serde_json::from_str::<Value>(input) else {
return ToolInput {
@@ -117,10 +76,6 @@ fn parse_object(tool: &str, json: &Map<String, Value>) -> ToolInput {
.find_map(|key| non_blank(json.get(*key)));
let timeout = non_blank(json.get("timeout")).map(|t| format_millis_text(&t));
// Sorted, so the leftovers are in the same order every time this call
// is drawn rather than in whatever order the JSON happened to arrive
// in. A field is left out only when it is already drawn somewhere
// else on the card.
let mut keys: Vec<&String> = json
.keys()
.filter(|k| Some(k.as_str()) != subject_key || subject.is_none())
@@ -148,9 +103,6 @@ mod tests {
#[test]
fn each_tool_in_the_table_has_its_own_subject() {
// One assertion per row of `SUBJECTS`, because the table is the
// whole of the rule and a row lost in an edit would otherwise
// only show up as a card with no summary line.
let cases = [
("Bash", r#"{"command":"ls -la"}"#, "ls -la"),
("Read", r#"{"file_path":"/tmp/x.rs"}"#, "/tmp/x.rs"),
@@ -175,9 +127,6 @@ mod tests {
#[test]
fn a_tools_own_description_is_what_the_one_line_says() {
// The description wins over the subject: it is the tool's own
// prose about what this call is for, which is what a reader
// scanning a collapsed run is looking for.
let parsed = parse_tool_input(
"Bash",
r#"{"command":"cargo test -p iris","description":"Run the iris tests"}"#,
@@ -196,9 +145,6 @@ mod tests {
#[test]
fn every_field_not_drawn_elsewhere_is_still_shown() {
// The half the "never dropped" promise is about: a tool this
// build has never heard of has no subject, so *everything* is
// rest -- and a known tool's extra fields are too.
let parsed = parse_tool_input(
"Edit",
r#"{"file_path":"/a.rs","old_string":"x","new_string":"y","replace_all":true}"#,
@@ -219,8 +165,6 @@ mod tests {
#[test]
fn input_that_is_not_an_object_is_still_the_input() {
// Older transcripts and some tools send a bare string; a card
// that dropped it would claim the call had no input at all.
assert_eq!(
parse_tool_input("Bash", "just a string").rest,
vec!["just a string".to_string()]
@@ -234,8 +178,6 @@ mod tests {
let parsed = parse_tool_input("Bash", r#"{"command":" ","other":1}"#);
assert_eq!(parsed.subject, None);
assert_eq!(parsed.title(), None);
// Not dropped just because it was blank -- it is still a field
// the call carried.
assert_eq!(
parsed.rest,
vec!["command: ".to_string(), "other: 1".to_string()]
-226
View File
@@ -1,53 +1,17 @@
//! This phone's copy of the transcripts it has already been sent, so
//! reopening a session does not download it again. Ported from
//! `app/.../TranscriptCache.kt`; see `docs/TRANSCRIPT_CACHE.md`
//! for the design and `docs/CLIENT_CORE.md` for how this file corresponds to it.
//!
//! What is stored is the server's own JSON for one event per line, in
//! transcript order. Reading the cache means running the same [`seq_of`]
//! the network path runs, so a cached transcript and a fetched one cannot
//! draw differently, and an event type this build does not know keeps
//! every field it arrived with for the build that will. Rows are
//! deliberately *not* what is stored: a row is a rendering, and a cache of
//! rows would need throwing away on every update that touched the fold.
//!
//! Four rules run through all of it:
//! 1. what is on screen is what the server's transcript says, in order,
//! with nothing missing -- the cache is a copy and is never inferred,
//! folded or edited here;
//! 2. a cached line is never ahead of the live cursor, and the cursor never
//! ahead of the cache;
//! 3. the cache is never load-bearing -- missing, evicted, damaged or
//! unwritable all degrade to a cold open, never to a blank or a wrong
//! screen;
//! 4. a line already on the phone is not fetched again.
//!
//! No JSON parser here: what it needs off a line is the sequence number and
//! whether the line is a streamed delta, both read with a regex-free scan
//! (see [`seq_of`] and [`is_delta`]). A line it cannot read that way is
//! treated as damage.
use std::collections::VecDeque;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
/// How much of this phone's cache directory all of one server's transcripts
/// may take. A dozen of the largest transcripts seen in the dev VM (21 MB
/// for 24,000 events) and a small fraction of a phone. A number to revisit
/// against real use rather than a measurement of anything.
pub const CACHE_BUDGET_BYTES: u64 = 256_000_000;
/// What the newest cached line says, which is what the probe checks against
/// the server.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CachedTail {
pub seq: u64,
pub line: String,
}
/// This phone's cache root for one server, holding one directory per session.
pub struct TranscriptCache {
root: PathBuf,
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
@@ -68,8 +32,6 @@ impl TranscriptCache {
}
}
/// The cache for one session, whether or not anything has been stored
/// for it yet.
pub fn session(&self, id: &str) -> SessionCache {
SessionCache::new(self.root.join(id), self.warn.clone())
}
@@ -164,36 +126,10 @@ fn dir_size(path: &Path) -> u64 {
.sum()
}
/// One session's cached lines, as a directory of chunks.
///
/// A chunk is a set of lines *and a claim about what they cover*, and the
/// two are not the same thing: a coalesced page joins each run of streamed
/// deltas into one event carrying the seq of the run's oldest delta, so a
/// page whose newest event is seq 1,200 may cover everything up to the
/// 1,650 it was fetched with, and nothing in the lines says so. So coverage
/// is the half-open range in the file's name:
/// `<first>-<end>.rows.jsonl` (a coalesced page; `end` is the `before` it
/// was fetched with) or `<first>-<end>.raw.jsonl` (an uncoalesced page, or a
/// closed live run); `<first>-open.raw.jsonl` is the live run, whose end is
/// its last line's seq + 1.
///
/// Two chunks are adjacent when one's `end` is the other's `first`. Only
/// the contiguous run ending at the newest chunk -- the **suffix** -- is
/// ever served: chunks behind a gap are kept, because the gap is usually
/// closed by paging back through it, but nothing is served across one.
///
/// **The newest chunk is always raw**, which is what makes the stream
/// cursor and the probe well defined.
///
/// Nothing here is load-bearing. Every operation that touches the disk
/// answers as though the cache were empty when it cannot, and a write
/// failure disables writing for the rest of this instance's life so that a
/// full disk costs one log line rather than one per delta.
///
/// A `Mutex` around the writer state stands in for Kotlin's `@Synchronized`:
/// the stream appends live events from its own thread while a reader
/// scrolling back reads pages from another, and this is what keeps the open
/// chunk's name, its end and its writer from being read half-rotated.
pub struct SessionCache {
dir: PathBuf,
warn: std::sync::Arc<dyn Fn(&str) + Send + Sync>,
@@ -202,8 +138,6 @@ pub struct SessionCache {
#[derive(Default)]
struct WriterState {
/// Set by the first write that fails: a second would fail the same way,
/// once per delta.
disabled: bool,
writer: Option<fs::File>,
open_file: Option<PathBuf>,
@@ -238,7 +172,6 @@ impl SessionCache {
})
}
/// The newest `limit` lines of the suffix, oldest first -- the opening window.
pub fn newest(&self, limit: usize) -> Vec<String> {
self.guard(Vec::new(), |this, state| {
let mut taken: VecDeque<String> = VecDeque::new();
@@ -262,11 +195,6 @@ impl SessionCache {
/// below `before` -- and means the server has to be asked. Deliberately
/// not an empty list: an empty page is how the screen is told it has
/// reached the start of the conversation.
///
/// With `rows` the count is rows rather than lines, mirroring the
/// server's `parse_coalesced`. The deltas are not joined here -- the
/// fold does that, and the joined row keeps the seq of its first delta
/// either way.
pub fn page(&self, before: u64, limit: usize, rows: bool) -> Option<Vec<String>> {
self.guard(None, |this, state| {
let suffix = this.suffix(state)?;
@@ -291,18 +219,12 @@ impl SessionCache {
continue;
}
this.each_line(state, chunk, |line| {
// The page is what is *before* the cursor; the rows at
// or above it are already on screen.
let seq = seq_of(line).expect("chunk lines are checked in each_line");
if seq >= before {
return true;
}
if rows {
let delta = is_delta(line);
// Stop only between rows: a delta continuing the
// run being gathered is part of a row already
// counted, and breaking on it would drop the half
// of that row already taken.
if counted >= limit && !(delta && in_run) {
wanting = false;
} else {
@@ -338,9 +260,6 @@ impl SessionCache {
})
}
/// Stores a fetched page covering `[first, end)`; `false` when it was
/// not stored.
///
/// Refused when it overlaps a chunk already here, because there is no
/// clean cut: a coalesced event cannot be split at a seq inside its own
/// delta run. The caller keeps that from arising by bounding what it
@@ -377,13 +296,6 @@ impl SessionCache {
})
}
/// Appends one live event, which is also how a freshly fetched opening
/// window is stored.
///
/// A seq equal to the open chunk's end extends it. A larger one is a
/// gap -- which is what a `reset` looks like from here -- and closes
/// the open chunk under the end it turned out to have. A smaller one is
/// already covered and is ignored; the SSE contract is `seq > after`.
pub fn append(&self, line: &str, seq: u64) {
self.guard((), |this, state| {
if state.disabled {
@@ -392,13 +304,6 @@ impl SessionCache {
let Some(writer) = this.writer_for(state, seq)? else {
return Ok(());
};
// Written as it arrived. A newline inside it would split one
// event into two unreadable halves. No source here can produce
// one -- an SSE `data:` field cannot hold a raw newline, and a
// fetched line is one element of a compact JSON array -- but
// that is a fact about the *server's* serializer rather than
// anything this file controls, so it is checked rather than
// trusted.
debug_assert!(
!line.contains('\n'),
"a cached transcript line must be one line: {line}"
@@ -411,7 +316,6 @@ impl SessionCache {
});
}
/// Flushes what [`Self::append`] has buffered.
pub fn flush(&self) {
self.guard((), |_this, state| {
if let Some(writer) = state.writer.as_mut() {
@@ -422,12 +326,10 @@ impl SessionCache {
});
}
/// What [`Self::purge`] would discard, for the reload row in session settings.
pub fn bytes(&self) -> u64 {
self.guard(0, |this, _state| Ok(dir_size(&this.dir)))
}
/// Marks this session as visited, which is what eviction ranks by.
pub fn touch(&self) {
self.guard((), |this, _state| {
if this.dir.is_dir() {
@@ -448,8 +350,6 @@ impl SessionCache {
});
}
// -- chunks ------------------------------------------------------------------------------
/// Every chunk on disk, oldest first. A name this does not recognise is
/// not ours and is ignored. Recomputed per operation rather than kept:
/// another operation may have changed the directory.
@@ -475,9 +375,6 @@ impl SessionCache {
} else {
end_str.parse::<u64>().ok()
};
// A chunk covering nothing is one that was created and never
// written to -- an append whose very first write failed. It
// says nothing, so it is not a chunk.
if let Some(end) = end
&& end > first
{
@@ -494,13 +391,6 @@ impl SessionCache {
Ok(out)
}
/// The open chunk's end: its last line's seq plus one, or the in-memory
/// end while this instance is the one writing it.
///
/// An open chunk whose last line cannot be read is this app having died
/// mid-write. That line is dropped and the file truncated to the last
/// good one, which is the one place damage is repaired rather than
/// discarded.
fn open_end_of(&self, state: &WriterState, file: &Path, first: u64) -> Option<u64> {
if state.open_file.as_deref() == Some(file) && state.open_end > 0 {
return Some(state.open_end);
@@ -516,12 +406,6 @@ impl SessionCache {
Some(end)
}
/// The contiguous run of adjacent chunks ending at the newest one,
/// oldest first.
///
/// A newest chunk that is not raw cannot happen while this code is the
/// only writer, and means the directory is not to be trusted -- so the
/// session is discarded.
fn suffix(&self, state: &mut WriterState) -> io::Result<Vec<Chunk>> {
let all = self.chunks(state)?;
let Some(newest) = all.last() else {
@@ -540,8 +424,6 @@ impl SessionCache {
Ok(run.into_iter().collect())
}
/// Each line of `chunk`, newest first, until `take` says stop.
///
/// Damage anywhere but at the tail of the open chunk was not written by
/// this code, and there is no honest way to say what a chunk covers
/// with a line of it unreadable -- so it is treated as damage rather
@@ -565,10 +447,6 @@ impl SessionCache {
});
}
// -- writing -----------------------------------------------------------------------------
/// The writer for the chunk `seq` belongs in, opening or rotating one
/// as it has to.
fn writer_for<'s>(
&self,
state: &'s mut WriterState,
@@ -581,14 +459,10 @@ impl SessionCache {
if seq < state.open_end {
return Ok(None);
}
// A gap: what this instance has written covers up to `open_end`,
// and that is the name the chunk gets before a new one starts
// at the arriving seq.
let end = state.open_end;
self.close_open_chunk(state, end);
}
fs::create_dir_all(&self.dir)?;
// An open chunk left by an earlier instance, or by an earlier screen.
let existing = self.chunks(state)?.into_iter().rfind(|c| c.open);
if let Some(existing) = existing {
if seq < existing.end {
@@ -632,8 +506,6 @@ impl SessionCache {
Ok(state.writer.as_mut())
}
/// Renames the open chunk to the range it turned out to cover, so it
/// stops being open.
fn close_open_chunk(&self, state: &mut WriterState, end: u64) {
let file = state.open_file.clone();
close_writer(state);
@@ -647,31 +519,17 @@ impl SessionCache {
}
}
// -- failure -----------------------------------------------------------------------------
/// Runs `body`, answering `if_broken` when the directory cannot give a
/// real answer. None of this is reported on screen: every read here has
/// a network path beside it producing the same result, and the reader
/// has nothing to do about it. Damage discards this session's cache,
/// which makes the next open an ordinary cold one.
fn guard<T>(
&self,
if_broken: T,
body: impl FnOnce(&Self, &mut WriterState) -> io::Result<T>,
) -> T {
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
// A disk that refused once will refuse again, once per delta, so
// the first refusal is also the last.
if state.disabled {
return if_broken;
}
DAMAGED.with(|cell| *cell.borrow_mut() = None);
let result = body(self, &mut state);
// Damage takes priority over whatever `body` returned, `Ok` or
// `Err`: `suffix` signals it by returning `Err(damaged(..))`
// precisely so this check catches it before the branch below
// mistakes it for a real I/O failure and disables the whole cache
// over one corrupt session.
if let Some(file) = DAMAGED.with(|cell| cell.borrow_mut().take()) {
(self.warn)(&format!(
"transcript cache damaged at {}; discarding {}",
@@ -695,11 +553,6 @@ impl SessionCache {
}
thread_local! {
/// How [`SessionCache::each_line`] reports a line it cannot make sense
/// of back up to [`SessionCache::guard`], since the callback it hands
/// `each_line_backwards` cannot itself return a `Result`. Thread-local
/// rather than a field: the guard that reads it always runs on the same
/// call stack that could have set it, one `guard` call at a time.
static DAMAGED: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
}
@@ -727,8 +580,6 @@ fn rename_chunk(file: &Path, dir: &Path, first: u64, end: u64) {
let _ = fs::rename(file, dir.join(format!("{first}-{end}.raw.jsonl")));
}
/// `<first>-<end|open>.<rows|raw>.jsonl`; anything else in the directory is
/// not ours.
fn parse_chunk_name(name: &str) -> Option<(u64, &str, &str)> {
let rest = name.strip_suffix(".jsonl")?;
let (rest, kind) = rest.rsplit_once('.')?;
@@ -744,22 +595,14 @@ fn parse_chunk_name(name: &str) -> Option<(u64, &str, &str)> {
}
/// One line's sequence number, or `None` when the line is not one of ours.
///
/// A hand-rolled scan rather than a JSON parse, so this module carries no
/// parser and stays testable with no server: the seq is the first field the
/// server writes, so the first match is the top-level one.
pub fn seq_of(line: &str) -> Option<u64> {
find_number_field(line, "seq")
}
/// Whether a line is one streamed piece of a reply, which is what makes a
/// run of them one row.
pub fn is_delta(line: &str) -> bool {
find_string_field(line, "type").as_deref() == Some("assistantText")
}
/// The value of `"key":N` (any amount of whitespace around the colon), or
/// `None`. Mirrors `Regex(""""seq"\s*:\s*(\d+)""")`'s first match.
fn find_number_field(line: &str, key: &str) -> Option<u64> {
let pattern = format!("\"{key}\"");
let at = line.find(&pattern)?;
@@ -777,8 +620,6 @@ fn find_number_field(line: &str, key: &str) -> Option<u64> {
}
}
/// The value of `"key":"..."`, or `None`. Mirrors
/// `Regex(""""type"\s*:\s*"([^"]*)"""")`'s first match.
fn find_string_field(line: &str, key: &str) -> Option<String> {
let pattern = format!("\"{key}\"");
let at = line.find(&pattern)?;
@@ -789,18 +630,11 @@ fn find_string_field(line: &str, key: &str) -> Option<String> {
Some(after_quote[..end].to_string())
}
/// How much of a file is read at a time when walking it backwards. One
/// block covers a page of a transcript comfortably, and the walk stops as
/// soon as the caller has what it asked for.
const READ_BLOCK: usize = 64 * 1024;
/// Calls `on_line` with each non-blank line of `file`, **newest first**,
/// along with the byte offset it starts at, until `on_line` answers false.
///
/// Every question the cache is asked is about the newest end of a chunk,
/// and a live run reaches the size of the conversation, so reading forwards
/// means reading a transcript to answer with the last eighty lines of it.
///
/// Splitting on bytes is safe because the separator is `\n`, which cannot
/// occur inside a multi-byte UTF-8 sequence; each line is decoded whole. A
/// missing file yields nothing.
@@ -823,7 +657,6 @@ fn each_line_backwards(file: &Path, mut on_line: impl FnMut(u64, &str) -> bool)
}
let mut buffer = block;
buffer.extend_from_slice(&pending);
// `buffer` is now `block` followed by `pending`; walk it backwards.
let mut line_end = buffer.len();
let mut at = buffer.len() as isize - 1;
while at >= 0 {
@@ -840,20 +673,12 @@ fn each_line_backwards(file: &Path, mut on_line: impl FnMut(u64, &str) -> bool)
pending = buffer[..line_end].to_vec();
unread = start;
}
// The first line of a file has no newline before it to be found.
let first = String::from_utf8_lossy(&pending);
if !first.trim().is_empty() {
on_line(0, &first);
}
}
/// Drops a final line that is not one of ours, by truncating the file to
/// where it starts.
///
/// This app having died mid-write is the one kind of damage that is
/// repaired rather than discarded: the tail of an append-only file is the
/// only place a partial line can be. A second bad line is not this, and is
/// left for the read path to notice.
fn repair_tail(file: &Path) -> io::Result<()> {
let mut truncate_to: Option<u64> = None;
each_line_backwards(file, |offset, line| {
@@ -869,9 +694,6 @@ fn repair_tail(file: &Path) -> io::Result<()> {
Ok(())
}
/// Runs `body`, translating an I/O or permission failure into `if_broken`
/// and a warning -- the disk half of [`SessionCache::guard`], shared with
/// [`TranscriptCache`]'s own maintenance.
fn guard_io<T>(
if_broken: T,
warn: &(impl Fn(&str) + ?Sized),
@@ -886,10 +708,6 @@ fn guard_io<T>(
}
}
/// Sets a path's modified time, without pulling in a crate for it: a single
/// `utimensat`-backed call would be one more platform-specific dependency
/// for one call site, so this touches the file instead, which every
/// filesystem this runs on updates the mtime for.
fn filetime_set_modified(path: &Path, _when: std::time::SystemTime) -> io::Result<()> {
use std::io::Write;
// Rewriting a marker file's contents (rather than the directory itself,
@@ -920,7 +738,6 @@ mod tests {
})
}
/// Like `cache`, but also hands back the messages it warned with.
fn cache_with_log(temp: &Path) -> (TranscriptCache, std::sync::Arc<Mutex<Vec<String>>>) {
let said: std::sync::Arc<Mutex<Vec<String>>> = Default::default();
let said2 = said.clone();
@@ -996,8 +813,6 @@ mod tests {
})
);
assert_eq!(session.newest(2), vec![tool_line(2), tool_line(3)]);
// More than there is is what there is, which is a short opening
// window and not a failure.
assert_eq!(session.newest(80).len(), 3);
}
@@ -1009,8 +824,6 @@ mod tests {
for seq in 1..=3u64 {
session.append(&tool_line(seq), seq);
}
// What a `reset` looks like from here: the next event is not the
// one after the last.
session.append(&tool_line(90), 90);
session.flush();
@@ -1052,14 +865,11 @@ mod tests {
}
session.flush();
// Adjacent: its end is the open chunk's first.
let page: Vec<String> = (60..100u64).map(tool_line).collect();
assert!(session.store_page(&page, 60, 100, true));
assert_eq!(seqs(&session.page(100, 2, false)), Some(vec![98, 99]));
assert_eq!(seqs_vec(&session.newest(80)).first(), Some(&60));
// Behind a gap: kept on disk, because paging usually closes the
// gap, but never served across it.
let page2: Vec<String> = (1..10u64).map(tool_line).collect();
assert!(session.store_page(&page2, 1, 10, true));
assert_eq!(session.page(10, 5, false), None);
@@ -1095,8 +905,6 @@ mod tests {
}
session.flush();
// At or below where the run starts, so what the reader is
// scrolling into is the server's.
assert_eq!(session.page(100, 40, true), None);
assert_eq!(session.page(40, 40, true), None);
assert_eq!(cache.session("never-visited").page(100, 40, true), None);
@@ -1121,8 +929,6 @@ mod tests {
let temp = tempfile::tempdir().unwrap();
let cache = cache(temp.path());
let session = cache.session("s");
// Two replies of three deltas each, split by a tool call: the same
// fixture as the server's `coalescing_counts_rows_and_joins_delta_runs`.
let lines = vec![
delta(1),
delta(2),
@@ -1137,14 +943,8 @@ mod tests {
session.append(&tool_line(9), 9);
session.flush();
// Three rows: the tool call at 8, the run 5..7, and the tool call
// at 4. The cut lands between rows, so the older run is not
// started.
assert_eq!(seqs(&session.page(9, 3, true)), Some(vec![4, 5, 6, 7, 8]));
// One row is one whole run, however many deltas it is made of.
assert_eq!(seqs(&session.page(9, 1, true)), Some(vec![8]));
// A page of lines counts lines, which is what the anchor restore
// asks for.
assert_eq!(seqs(&session.page(9, 2, false)), Some(vec![7, 8]));
}
@@ -1163,10 +963,7 @@ mod tests {
session.append(&tool_line(10), 10);
session.flush();
// A run straddling the boundary is one row, as it will be once folded.
assert_eq!(seqs(&session.page(11, 2, true)), Some(vec![8, 9, 10]));
// Asking for more rows than the suffix holds is a short page, not a
// failure and not a claim that the conversation starts here.
assert_eq!(seqs(&session.page(11, 40, true)), Some((5..=10).collect()));
}
@@ -1190,13 +987,9 @@ mod tests {
session.append(&tool_line(90), 90);
session.flush();
// The run behind the gap, which is what makes the fetched page
// adjacent to it.
assert_eq!(session.covered_up_to(90), Some(40));
assert_eq!(session.covered_up_to(41), Some(40));
assert_eq!(session.covered_up_to(10), Some(10));
// Nothing at or below the oldest chunk's start, so the page is
// bounded only by its limit.
assert_eq!(session.covered_up_to(9), None);
}
@@ -1212,9 +1005,6 @@ mod tests {
&(1..10u64).map(tool_line).collect::<Vec<_>>(),
);
// Only reachable by dying between closing one live run and opening
// the next, and there is no cursor to be read off a coalesced line
// -- so the open is a cold one.
assert_eq!(session.tail(), None);
assert!(!dir_of(temp.path(), "s").exists());
}
@@ -1243,7 +1033,6 @@ mod tests {
fs::read_to_string(dir.join("1-open.raw.jsonl")).unwrap(),
format!("{}\n{}\n", tool_line(1), tool_line(2))
);
// And the run continues from where the good tail left off.
session.append(&tool_line(3), 3);
session.flush();
assert_eq!(seqs_vec(&session.newest(80)), vec![1, 2, 3]);
@@ -1261,7 +1050,6 @@ mod tests {
&[tool_line(1), "not ours".to_string(), tool_line(3)],
);
// Not seen by the tail, which reads the newest line and stops.
assert_eq!(
session.tail(),
Some(CachedTail {
@@ -1269,8 +1057,6 @@ mod tests {
line: tool_line(3)
})
);
// Reached by a read that walks past it: what is served is nothing,
// and the session opens cold from here on.
assert_eq!(session.newest(80), Vec::<String>::new());
assert!(!dir_of(temp.path(), "s").exists());
assert!(said.lock().unwrap().iter().any(|m| m.contains("damaged")));
@@ -1298,9 +1084,6 @@ mod tests {
let temp = tempfile::tempdir().unwrap();
let cache = cache(temp.path());
let session = cache.session("s");
// Well past the 64 kB block the backwards reader takes at a time,
// so a page has to be stitched across several of them -- including
// a line that straddles a boundary.
let padding = "x".repeat(300);
let lines: Vec<String> = (1..=500u64)
.map(|seq| format!(r#"{{"seq":{seq},"ts":1.5,"type":"toolStart","id":"{padding}"}}"#))
@@ -1310,8 +1093,6 @@ mod tests {
assert_eq!(session.tail().unwrap().seq, 500);
assert_eq!(session.newest(80), lines[420..].to_vec());
assert_eq!(session.page(401, 999, false), Some(lines[0..400].to_vec()));
// And a non-ASCII line, whose bytes a naive split could cut through
// a character.
let accented =
r#"{"seq":501,"ts":1.5,"type":"assistantText","delta":"héllo — ok"}"#.to_string();
session.append(&accented, 501);
@@ -1333,15 +1114,10 @@ mod tests {
let when =
std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000 + at as u64);
filetime_set_modified(&dir_of(temp.path(), id), when).unwrap();
// The mtime touch above always sets "now", not `when` (see its
// own doc) -- space the three writes out in real time instead,
// since only relative order matters to eviction.
std::thread::sleep(std::time::Duration::from_millis(10));
}
let each = dir_size(&dir_of(temp.path(), "old"));
// Room for two of the three, so the oldest goes -- and the session
// being read never does, however long ago it was last touched.
cache.evict_to_budget("open", each * 2);
let mut remaining = fs::read_dir(temp.path().join("v1/host_8443"))
.unwrap()
@@ -1394,8 +1170,6 @@ mod tests {
session.purge();
assert_eq!(session.bytes(), 0);
assert_eq!(session.tail(), None);
// And the session is usable again straight afterwards, which is
// what a reload does next.
session.append(&tool_line(9), 9);
session.flush();
assert_eq!(seqs_vec(&session.newest(80)), vec![9]);
-183
View File
@@ -1,32 +1,5 @@
//! What the transcript renders: the event stream folded into displayable
//! rows. Ported from `app/.../TranscriptItems.kt` and `ToolRows.kt`'s
//! non-Compose half (`TranscriptRow`, `groupToolRuns`).
//!
//! Events are the only data source, and there is deliberately no second
//! shape for history to drift from: a page fetched backwards, a live
//! frame, and a line read out of the transcript cache are all the same
//! events through the same fold.
//!
//! **Not ported**: `TranscriptUnits.kt`'s further flatten of a row into
//! Compose list units (`TranscriptUnit`, `transcriptUnits`) -- that layer
//! exists to bound how much a lazy list composes per frame, which is a
//! fact about the UI framework drawing it, not about the transcript. See
//! `CLIENT_CORE.md`.
//!
//! **Known gap**: unlike `Events.kt`'s hand-kept mirror, this crate
//! deserializes straight into [`event_model::Event`], which has no
//! `Unknown` catch-all -- an event type this build does not recognise
//! fails to parse rather than degrading to a placeholder row. Closing that
//! gap means giving `event_model::Event` its own forward-compatible
//! variant, which is a shared-model decision for both sides of the wire
//! and is deliberately left for whoever picks this up next (see
//! `CLIENT_CORE.md`).
use event_model::{Event, QuestionOption, SeqEvent, SessionStatus};
/// A question this build has already asked the reader about, with what was
/// answered so far -- distinct from [`QuestionOption`], which is what could
/// be chosen.
#[derive(Debug, Clone, PartialEq)]
pub struct QuestionCard {
pub seq: u64,
@@ -38,24 +11,14 @@ pub struct QuestionCard {
pub answers: Vec<String>,
}
/// A tool call cannot be recognised as `AskUserQuestion` from a bare
/// `ToolEnd` (its name is not carried), so `runIdFor` and the run-adoption
/// logic name it explicitly.
pub const ASK_USER_QUESTION: &str = "AskUserQuestion";
/// This item's identity in the list: a `Seq` for everything with no
/// identity of its own, `RunId` for a tool call (which keeps one across
/// however many calls join or leave its run), matching `TranscriptItem.key`
/// in the Kotlin original.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ItemKey {
Seq(u64),
RunId(String),
}
/// One row of the transcript, folded from [`Event`]s. See each variant's
/// Kotlin counterpart in `TranscriptItem` for the fuller rationale; this
/// doc only says what changed in translation.
#[derive(Debug, Clone, PartialEq)]
pub enum TranscriptItem {
UserMsg {
@@ -66,8 +29,6 @@ pub enum TranscriptItem {
AssistantMsg {
seq: u64,
text: String,
/// Whether this reply is finished -- see `AssistantMsg.settled`'s
/// Kotlin doc for why the split it licenses matters.
settled: bool,
},
ToolRun {
@@ -95,9 +56,6 @@ pub enum TranscriptItem {
seq: u64,
r#ref: String,
},
/// A message from another agent. `arrived` is this row's own identity
/// ([`TranscriptItem::key`]); `seq` is where it *sorts*, which
/// [`place_peer_note`] may set to the turn's opening seq instead.
PeerNote {
seq: u64,
from: String,
@@ -108,8 +66,6 @@ pub enum TranscriptItem {
seq: u64,
text: String,
},
/// Placeholder for an event kind this build could not fold -- see the
/// module doc's "known gap".
Note {
seq: u64,
text: String,
@@ -201,14 +157,10 @@ fn update_tool(
.collect()
}
/// Whether a status means the session is still doing something, mirroring
/// `sessionWorking` in `Events.kt`.
pub fn session_working(status: SessionStatus) -> bool {
matches!(status, SessionStatus::Running | SessionStatus::Compacting)
}
/// A status saying the session stopped working is the moment its newest
/// reply is finished.
fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<TranscriptItem> {
if session_working(status) {
return items.to_vec();
@@ -223,9 +175,6 @@ fn settle_reply(items: &[TranscriptItem], status: SessionStatus) -> Vec<Transcri
items
}
/// A peer message goes above the turn it started, not where it happened to
/// arrive. See the Kotlin `placePeerNote`'s doc for the full reasoning;
/// `turn_start` is `Event::PeerMessage`'s own field of that name.
fn place_peer_note(
items: &[TranscriptItem],
seq: u64,
@@ -264,8 +213,6 @@ fn place_peer_note(
out
}
/// The calls the note now sits in front of, renamed if they were sharing a
/// run with the calls behind it. See the Kotlin `splitRun`'s doc.
fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptItem> {
let Some(TranscriptItem::ToolRun {
run_id: first_run_id,
@@ -299,14 +246,6 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptIte
out
}
/// Puts a page of older items in front of the ones already loaded, healing
/// whatever the page boundary cut in two. Ported from `TranscriptItems.kt`'s
/// `joinPages`.
///
/// Two things straddle a boundary: a tool call separated from its result,
/// and a message separated from the rest of itself. Both were one thing
/// before the transcript was cut into pages.
///
/// A boundary lands wherever it lands, and roughly half the time that is
/// between a call and its result. The newer page then holds a `ToolEnd`
/// whose start it never saw, which `fold_event` draws as a row of its own
@@ -319,23 +258,12 @@ fn split_run(tail: &[TranscriptItem], behind: Option<&str>) -> Vec<TranscriptIte
/// exactly what a page boundary destroys. The older row wins on what a
/// start knows and the newer on what an end knows, which is the only way
/// round that loses nothing.
///
/// The third thing is the *run*, and it is the one the Kotlin original used
/// to miss (AGENTS.md's "things that have bitten"): every page ends up
/// here, but `adopt_run` must run on *every* join, not only the one where a
/// split call was found -- a boundary landing cleanly between two finished
/// calls, which is most of them, would otherwise leave the older page's
/// calls under the run name they were folded with. On screen: one run of
/// tool calls drawn as two groups, with the seam wherever the reader
/// happened to have paged.
pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
let (older, newer) = heal_split_message(earlier, later);
let started_earlier: std::collections::HashSet<&str> = older
.iter()
.filter_map(TranscriptItem::as_tool_run)
.collect();
// Owned rather than borrowed from `newer`: `kept` below needs to consume `newer` by
// value, and a map borrowing it would keep that alive.
let ended_later: std::collections::HashMap<String, TranscriptItem> = newer
.iter()
.filter_map(|item| item.as_tool_run().map(|id| (id.to_string(), item.clone())))
@@ -374,9 +302,6 @@ pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<T
output: output.clone(),
done,
failed,
// Kept from both halves: a question or an image can be
// attached to either, depending on which side of the
// boundary its event fell.
asks: row_asks.into_iter().chain(half_asks.clone()).collect(),
images: row_images.into_iter().chain(half_images.clone()).collect(),
}
@@ -411,18 +336,10 @@ pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<T
out
}
/// Rejoins a message the page boundary cut, and hands back the two pages to
/// concatenate. Ported from `TranscriptItems.kt`'s `healSplitMessage`.
///
/// `fold_event` never leaves two assistant messages next to each other
/// inside one page, so two meeting at a join are always the two halves of
/// one reply, and leaving them apart drew a single answer as two with a
/// paragraph break through the middle of a sentence.
///
/// The newer half keeps its identity, for the reason `adopt_run`'s doc
/// gives. It grows by what the older half brings, which is safe here and
/// nowhere else -- the join is at the oldest end of what is loaded, so the
/// growth extends off the top of the screen.
fn heal_split_message(
earlier: &[TranscriptItem],
later: &[TranscriptItem],
@@ -450,21 +367,10 @@ fn heal_split_message(
(earlier[..earlier.len() - 1].to_vec(), newer)
}
/// Hands the older calls at the join the name of the run they are joining.
/// Ported from `TranscriptItems.kt`'s `adoptRun`.
///
/// The two pages were folded separately, so a run split by the boundary
/// came back as two runs with two names. Naming the joined run after the
/// *older* half would be the obvious way round and is wrong: the newer half
/// is the part already on screen, and renaming it is renaming the row the
/// reader is looking at, which is how a list loses its anchor.
fn adopt_run(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<TranscriptItem> {
let Some(TranscriptItem::ToolRun { run_id, tool, .. }) = later.first() else {
return earlier.to_vec();
};
// A question is in a run of its own on both sides of the join, the same as it would be
// had the two pages been folded as one. Without this the heal would merge a group
// straight through the row the reader was asked something on.
if tool == ASK_USER_QUESTION {
return earlier.to_vec();
}
@@ -494,10 +400,6 @@ fn adopt_run(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<Transc
out
}
/// Folds one transcript event onto `items`, the way `foldEvent` does in
/// `TranscriptItems.kt`. Every wire event has a case; see the module doc
/// for the one difference from the Kotlin original (no `Unknown` fallback
/// at the parse layer).
pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptItem> {
let seq = entry.seq;
match &entry.event {
@@ -512,9 +414,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
});
items
}
// `MessageTaken` is folded into `UserMessage` by the manager before
// it reaches a phone (see `PLAN.md`); if one arrives here anyway
// (a raw transcript line, say), it reads the same way.
Event::MessageTaken {
text, attachments, ..
} => {
@@ -527,12 +426,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
items
}
Event::AssistantText { delta } => {
// Deltas accumulate into the message they're streaming, which
// keeps the seq of the *first* of them: a row whose identity
// changed with every delta would be a new row every frame.
// "A message growing again is not finished" -- whatever a
// status said in between -- is why this always clears
// `settled` rather than preserving it.
if let Some(TranscriptItem::AssistantMsg {
seq: first_seq,
text,
@@ -704,7 +597,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
});
items
}
// Screen-level state, not transcript rows.
Event::CommandQueued { .. }
| Event::MessageQueued { .. }
| Event::MessageDropped { .. }
@@ -769,9 +661,6 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
}
}
/// What became of one tool call -- every state a card has to be able to
/// draw, including the two that are not answers.
///
/// The pair this enum exists for is [`ToolState::Succeeded`] against
/// [`ToolState::NoResult`]. A call that finished having printed nothing
/// and a call whose result never arrived both leave an empty `output`,
@@ -780,34 +669,18 @@ pub fn fold_event(items: &[TranscriptItem], entry: &SeqEvent) -> Vec<TranscriptI
/// turn ended before anything came back.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolState {
/// Started, no result yet, and the session is still working -- the
/// ordinary state of a call in flight.
Running,
/// Stopped on the reader: a permission or question this call carries
/// has not been answered, so nothing is happening until somebody
/// answers it. Distinct from [`Self::Running`] because whose move it
/// is differs, which is the Compose card's "your turn".
Deciding,
/// A result arrived and the tool did not report a failure.
Succeeded,
/// A result arrived and the tool reported that the call failed
/// (`is_error`).
Failed,
/// No result ever arrived and the session is not working any more --
/// the turn was interrupted, or the process went away. Not a verdict
/// on the call: it says only that nobody found out.
NoResult,
}
impl ToolState {
/// The state of one call. `session_working` is
/// [`session_working`]'s answer for the session this call is in --
/// the only thing here that is not a property of the call itself, and
/// what separates "still running" from "never came back".
///
/// Written once, over the fields rather than per call site, because
/// the five states are decided by four conditions and every place
/// that re-derived a subset of them got a different subset.
pub fn of(item: &TranscriptItem, session_working: bool) -> Option<Self> {
let TranscriptItem::ToolRun {
done, failed, asks, ..
@@ -820,9 +693,6 @@ impl ToolState {
"a call cannot have failed before its result arrived"
);
Some(if asks.iter().any(|ask| ask.answers.is_empty()) {
// Ahead of `done`: a call waiting on permission has not
// finished either, and which of the two the reader is being
// told about is the one they can act on.
Self::Deciding
} else if !*done {
match session_working {
@@ -837,14 +707,9 @@ impl ToolState {
}
}
/// One row as the transcript draws it: a run of consecutive tool calls, or
/// anything else. Ported from `ToolRows.kt`'s `TranscriptRow` and
/// `groupToolRuns` -- the Compose card rendering in that file is not part
/// of this crate.
#[derive(Debug, Clone, PartialEq)]
pub enum TranscriptRow {
Single(TranscriptItem),
/// Two or more calls with nothing between them.
Tools(Vec<TranscriptItem>),
}
@@ -864,9 +729,6 @@ impl TranscriptRow {
}
}
/// Runs of adjacent tool calls become one row; everything else passes
/// through. See the Kotlin `groupRuns`'s doc for why grouping is by the
/// run each call names rather than by adjacency worked out here.
pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
let mut rows = Vec::new();
let mut run: Vec<TranscriptItem> = Vec::new();
@@ -902,15 +764,6 @@ pub fn group_tool_runs(items: &[TranscriptItem]) -> Vec<TranscriptRow> {
rows
}
/// Folds a page of raw transcript lines (`ApiClient::fetch_transcript_page`'s
/// `Vec<Value>`) into the flat item list this module works over. A line
/// this build can't parse fails the whole page rather than being skipped --
/// CODE_RULES's "an enumeration must be able to say 'it broke'" -- since
/// silently dropping one event could hide, say, a user message that then
/// looks like it was never sent. Moved here from `desktop-app`'s `app.rs`
/// (RUST.md's E4) when the Android transcript client (I5) needed the same
/// fold: "write the logic once" applies to any caller embedding
/// `transcript-ui` against a live server, not just the first one.
pub fn fold_page(values: &[serde_json::Value]) -> Result<Vec<TranscriptItem>, String> {
let mut items = Vec::new();
for value in values {
@@ -922,13 +775,6 @@ pub fn fold_page(values: &[serde_json::Value]) -> Result<Vec<TranscriptItem>, St
Ok(items)
}
/// The wire `seq` a raw transcript line carries -- the live-stream resume
/// cursor after loading a page must be this, not a folded item's `seq()`.
/// A folded `AssistantMsg` keeps the seq of the *first* delta it
/// accumulated (`fold_event`'s own doc), so resuming from that seq would
/// re-deliver every delta already folded into it, duplicating the tail of
/// a reply that was mid-stream when the page was fetched -- found via a
/// real screenshot in E4 (RUST.md), where the assistant's line doubled.
pub fn raw_seq(value: &serde_json::Value) -> Option<u64> {
value.get("seq")?.as_u64()
}
@@ -1161,12 +1007,6 @@ mod tests {
obj
}
/// The regression for a bug a real `run-headless.sh` screenshot found
/// in `desktop-app` (E4, RUST.md): resuming the live stream from the
/// last *item's* seq re-delivers the deltas already folded into a
/// still-open assistant message, doubling its tail. `raw_seq` of the
/// last wire line must be the true high-water mark instead, which for a
/// run of deltas is higher than every item's own `seq()`.
#[test]
fn the_resume_cursor_is_the_last_wire_seq_not_the_last_items_seq() {
let values = vec![
@@ -1260,13 +1100,6 @@ mod tests {
)
}
/// AGENTS.md's "things that have bitten": `joinPages` used to run
/// `adoptRun` only on the path where a *split* call was found, so a
/// boundary landing cleanly between two already-finished calls -- most
/// of them -- left the older page's calls under the run name they were
/// folded with, drawing one run of tool calls as two groups. Two
/// finished, unrelated calls (no id in common) must still end up under
/// one run name after the join.
#[test]
fn a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run() {
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "old output")]);
@@ -1346,9 +1179,6 @@ mod tests {
);
}
/// A question is in a run of its own on both sides of a join -- healing
/// must never rename the run of calls the reader was asked something
/// on, the same rule `splitRun` enforces for a live turn boundary.
#[test]
fn adopt_run_never_renames_into_a_question_row() {
let older = fold_all(&[tool_start(1, "a", "Bash"), tool_end(2, "a", "done")]);
@@ -1372,10 +1202,6 @@ mod tests {
}
}
/// [`ToolState`] is what a card colours itself by, so each of its five
/// states is asserted from the events that actually produce it rather than
/// from a hand-built item -- a mapping that agreed with a fixture and
/// disagreed with the fold would be invisible until it was on screen.
#[cfg(test)]
mod tool_state_tests {
use super::*;
@@ -1433,10 +1259,6 @@ mod tool_state_tests {
);
}
/// The pair this enum exists for. Both calls have an empty `output`
/// and nothing else distinguishes them, so a card that only looked at
/// the text would draw the interrupted one as a call that ran fine and
/// printed nothing.
#[test]
fn a_call_that_printed_nothing_is_not_a_call_that_never_answered() {
assert_eq!(
@@ -1451,9 +1273,6 @@ mod tool_state_tests {
);
}
/// The same call, mid-turn: still running rather than abandoned. The
/// only thing separating the two is the session's own status, which is
/// why `of` takes it.
#[test]
fn no_result_while_the_session_works_is_still_running() {
assert_eq!(state_of(&[start("a")], true), ToolState::Running);
@@ -1483,8 +1302,6 @@ mod tool_state_tests {
answers: vec!["Allow".to_string()],
},
);
// Ahead of both "still running" and "no result": the reader can
// act on this one, and cannot act on either of those.
assert_eq!(
state_of(&[start("a"), asking.clone()], true),
ToolState::Deciding
-109
View File
@@ -1,33 +1,9 @@
//! Where a session screen gets a transcript from: this phone's copy first,
//! the server for the rest. Ported from `app/.../TranscriptSource.kt`; see
//! `docs/TRANSCRIPT_CACHE.md` for the design this implements and
//! `docs/CLIENT_CORE.md` for how this file corresponds to the Kotlin.
//!
//! One seam rather than a cache the screen has to remember to consult.
//! Everything fetched before is asked of this, and everything the server
//! sends is written into the cache on the way past, so a caller never
//! learns which side answered. The one rule worth keeping in mind: the
//! cache is never load-bearing. Every read here has a network path beside
//! it producing the same result.
//!
//! **Not ported**: `EventStream.kt`'s reconnect-with-backoff loop and the
//! ability to close a live stream from another thread. Both are wall-clock
//! and thread-lifetime concerns that belong to whatever runtime the caller
//! embeds this crate in (a Tokio task, an iris timer, a Kotlin coroutine
//! scope) rather than to this pure logic -- `follow` below is the same
//! decorator shape `iris/desktop-app/src/app.rs` and
//! `iris/android-app/src/transcript_client.rs` already hand-wrote around
//! `event_stream::follow_session_events`, just with the cache write built
//! in so a future caller does not have to repeat it a third time.
use event_model::SeqEvent;
use crate::client::api::{ApiClient, ApiError, Transport};
use crate::client::event_stream::{self, StreamItem};
use crate::client::transcript_cache::SessionCache;
/// How many events a session screen opens with, cached or fetched.
///
/// The server's own default page size, named here because the cached
/// opening has to be the same size as the fetched one -- a reader must not
/// get a shorter first screen for having been here before (`OPENING_WINDOW`
@@ -49,9 +25,6 @@ impl std::fmt::Display for ParseError {
}
impl std::error::Error for ParseError {}
/// Either half of what can go wrong asking for a page: the network, or a
/// line neither the cache's nor the server's copy of `parseSeqEvent` could
/// read.
#[derive(Debug, Clone)]
pub enum PageError {
Api(ApiError),
@@ -70,23 +43,9 @@ impl From<ParseError> for PageError {
}
}
/// What [`TranscriptSource::page`] found, kept as two states rather than
/// one possibly-empty list.
///
/// The difference is the whole of AGENTS.md's `loadOlderPage` incident: an
/// empty [`Self::Events`] means "this conversation has no more history",
/// which a caller is meant to latch, and [`Self::NothingLoaded`] means the
/// question could not be asked yet, which it must not. Collapsing the two
/// into an empty `Vec` puts the bug back, because the caller cannot tell
/// them apart -- and `unwrap_or_default()` on an `Option` would do the
/// same silently.
#[derive(Debug, Clone, PartialEq)]
pub enum OlderPage {
/// The events before the cursor, oldest first. Empty means the start
/// of the conversation has been reached.
Events(Vec<SeqEvent>),
/// Nothing is loaded, so there was no cursor to page back from
/// (`before == 0`). Not an answer about the conversation at all.
NothingLoaded,
}
@@ -94,8 +53,6 @@ fn parse_line(line: &str) -> Result<SeqEvent, ParseError> {
serde_json::from_str(line).map_err(|e| ParseError(format!("{e}")))
}
/// This phone's copy of one session's transcript, plus the server it
/// falls back to. Ported from the Kotlin `TranscriptSource` class.
pub struct TranscriptSource<T: Transport> {
api: ApiClient<T>,
session_id: String,
@@ -113,11 +70,6 @@ impl<T: Transport> TranscriptSource<T> {
/// The cached opening window, or `None` when there is nothing usable
/// to draw.
///
/// Meant to be drawn *before* [`Self::probe`] returns, which is the
/// whole point of the feature: the rows are on screen while the check
/// that they are still the server's rows is in flight, and a failed
/// check replaces them exactly as a reset does.
pub fn cached_opening(&self, limit: usize) -> Option<Vec<SeqEvent>> {
self.cache.tail()?;
let lines = self.cache.newest(limit);
@@ -126,9 +78,6 @@ impl<T: Transport> TranscriptSource<T> {
}
match lines.iter().map(|l| parse_line(l)).collect() {
Ok(events) => Some(events),
// A line this build cannot read at all, which the cache's own checks cannot
// see: it reads a seq off a line, not an event. Nothing to serve, so a cold
// open.
Err(ParseError(_)) => {
self.cache.purge();
None
@@ -136,9 +85,6 @@ impl<T: Transport> TranscriptSource<T> {
}
}
/// Whether the server's event at the cached cursor is still the cached
/// one.
///
/// A caller must not resume a live stream from a cached seq unless it
/// is the same conversation: a transcript is append-only in ordinary
/// use, but the file backing it can be replaced or truncated (a
@@ -151,15 +97,10 @@ impl<T: Transport> TranscriptSource<T> {
/// `Ok(false)` purges the cache and means "open cold". `Err` is the
/// server not being askable, which is neither: the cached rows stay
/// on screen and the caller tries again on its own reconnect schedule.
///
/// What this cannot see is a line changed in the middle of the file
/// with the tail intact -- that is what a full reload is for.
pub fn probe(&self) -> Result<bool, ApiError> {
let Some(tail) = self.cache.tail() else {
return Ok(false);
};
// `before = seq + 1` is the newest event with seq <= the cursor, which is the
// event *at* the cursor when the server still has one there.
let page = self.api.fetch_transcript_lines(
&self.session_id,
Some(tail.seq + 1),
@@ -177,9 +118,6 @@ impl<T: Transport> TranscriptSource<T> {
Ok(matches)
}
/// Today's opening fetch, kept as the start of the live run. Only
/// called when the cache has nothing to open with, or when
/// [`Self::probe`] said what it had was not the server's.
pub fn fetch_opening(&self) -> Result<Vec<SeqEvent>, ApiError> {
let page =
self.api
@@ -193,21 +131,6 @@ impl<T: Transport> TranscriptSource<T> {
/// The page before `before`: from the cache when it holds it,
/// otherwise from the server bounded by what the cache already has.
///
/// The server bound (`after`) is what keeps the cache worth having. A
/// coalesced page reaches back as far as its row count takes it -- a
/// single reply is hundreds of lines -- so a page fetched after the
/// reader has been away could run straight past the cached run and
/// overlap it, and an overlapping page cannot be stored. Told where
/// this phone's copy starts, the server stops there instead.
///
/// `before == 0` answers [`OlderPage::NothingLoaded`] without asking
/// the cache or the server anything -- see AGENTS.md's "things that
/// have bitten": there is no event before the first one, so the
/// request is not a harmless no-op, and its empty answer is
/// indistinguishable from having reached the start of history.
/// Guarded here rather than left to every caller, because it is a fact
/// about the question, not about who is asking it.
pub fn page(&self, before: u64, limit: u32, coalesce: bool) -> Result<OlderPage, PageError> {
if before == 0 {
return Ok(OlderPage::NothingLoaded);
@@ -228,9 +151,6 @@ impl<T: Transport> TranscriptSource<T> {
after,
)?;
if let Some((_, first_event)) = page.first() {
// `before` rather than the newest line's seq: a coalesced page covers
// everything up to the cursor it was asked with, and nothing in its lines
// says so.
let lines: Vec<String> = page.iter().map(|(line, _)| line.clone()).collect();
self.cache
.store_page(&lines, first_event.seq, before, coalesce);
@@ -240,9 +160,6 @@ impl<T: Transport> TranscriptSource<T> {
))
}
/// [`event_stream::follow_session_events`], with every frame written to
/// the cache before `on_item` sees it.
///
/// Before, so that an event held back for a reader who is scrolled
/// away is already on disk -- what the cache holds is what the server
/// sent, not what a screen has got round to drawing. Flushed on each
@@ -289,11 +206,6 @@ mod tests {
use std::io::Read;
use std::sync::Mutex;
/// A transport that answers fixed bodies in call order, and records
/// every path it was asked for -- so a test can assert *how many*
/// requests a method made, which is the point for the `before == 0`
/// guard (AGENTS.md's regression: the guard must stop the request
/// before it happens, not merely tolerate the empty answer).
#[derive(Default)]
struct ScriptedTransport {
responses: Mutex<VecDeque<(u16, String)>>,
@@ -371,7 +283,6 @@ mod tests {
let opening = source.fetch_opening().unwrap();
assert_eq!(opening.len(), 1);
assert_eq!(opening[0].seq, 1);
// The fetch wrote through: reopening the same cache now has something to show.
assert!(source.cache.tail().is_some());
}
@@ -399,8 +310,6 @@ mod tests {
let source = source(transport, dir.path());
source.fetch_opening().unwrap();
// The server now answers with a different event at the same seq -- the file
// behind this session was replaced.
let transport2 = ScriptedTransport::default();
let different = r#"{"seq":1,"ts":1.0,"type":"status","state":"running"}"#.to_string();
transport2.respond(200, format!("[{different}]"));
@@ -429,10 +338,6 @@ mod tests {
);
}
/// The regression this module exists to close: `before == 0` must
/// never reach the network or the cache, because an empty answer there
/// is indistinguishable from "there is genuinely no more history" --
/// AGENTS.md's `loadOlderPage` incident.
#[test]
fn paging_before_the_first_event_makes_no_request_at_all() {
let dir = tempfile::tempdir().unwrap();
@@ -463,8 +368,6 @@ mod tests {
);
}
/// With nothing older cached there is no floor to give the server, so
/// the request carries no `after` at all.
#[test]
fn a_server_page_with_nothing_older_cached_carries_no_bound() {
let dir = tempfile::tempdir().unwrap();
@@ -484,17 +387,10 @@ mod tests {
);
}
/// The half the test above cannot show: when the cache *does* hold an
/// older run, the fetch is floored at its end, or the page would run
/// straight past it and overlap -- which `store_page` then refuses,
/// silently costing the phone the page it just paid for.
#[test]
fn a_server_page_is_floored_at_the_end_of_the_cached_run() {
let dir = tempfile::tempdir().unwrap();
let cache = crate::client::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
// A stored page covering [3, 6) and two live events above it, so the run this
// phone holds is [3, 8) -- the newest chunk has to be an appended one, or the
// cache reads the directory as damaged and discards it.
let lines: Vec<String> = (3..6).map(status_line).collect();
assert!(cache.store_page(&lines, 3, 6, true));
cache.append(&status_line(6), 6);
@@ -512,9 +408,6 @@ mod tests {
);
}
/// A page the server could not answer is an error, never an empty
/// page: the caller would read the second as "this conversation has no
/// more history" and stop paging for good.
#[test]
fn a_failing_server_page_is_an_error_rather_than_an_empty_one() {
let dir = tempfile::tempdir().unwrap();
@@ -524,8 +417,6 @@ mod tests {
assert!(matches!(source.page(9, 10, true), Err(PageError::Api(_)),));
}
/// A cached line this build cannot read is told apart from the network
/// failing, for the same reason: neither is "no more history".
#[test]
fn an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one() {
let dir = tempfile::tempdir().unwrap();